From 3ad29c970086b5ba4462a4c8a9c41dab6fb5a0df Mon Sep 17 00:00:00 2001 From: ClemLy Date: Wed, 17 Jun 2026 11:49:53 +0200 Subject: [PATCH 01/31] feat: refonte ux des exercices avec autocompletion intelligente, mode global tout-en-un et suppression de serie --- front/App.js | 1 + front/src/components/cards/SetRow.js | 107 ++++++-- .../workouts/InlineExerciseBlock.js | 238 ++++++++++++++++++ front/src/components/workouts/SetTable.js | 84 ++++++- front/src/hooks/useWorkoutState.js | 22 +- .../src/screens/Profile/EditProfileScreen.js | 38 ++- .../screens/Workouts/ExerciseDetailScreen.js | 6 + front/src/screens/Workouts/WorkoutScreen.js | 83 +++++- 8 files changed, 536 insertions(+), 43 deletions(-) create mode 100644 front/src/components/workouts/InlineExerciseBlock.js diff --git a/front/App.js b/front/App.js index 8d30dd5..6c2dd4d 100644 --- a/front/App.js +++ b/front/App.js @@ -3,6 +3,7 @@ import { Platform, StatusBar } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { AuthProvider } from './src/context/AuthContext'; import { ToastProvider } from './src/context/ToastContext'; + import AppNavigator from './src/navigation'; import DesktopInstallPage from './src/components/web/DesktopInstallPage'; diff --git a/front/src/components/cards/SetRow.js b/front/src/components/cards/SetRow.js index 7ecf862..aebe6df 100644 --- a/front/src/components/cards/SetRow.js +++ b/front/src/components/cards/SetRow.js @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { View, Text, @@ -10,25 +10,42 @@ import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; import { Colors } from '../../constants/theme'; -// Ligne de série : SET | POIDS (KG) | REPS | VALIDER -// Quand completed : ligne grisée, inputs verrouillés, bouton "✓ Annuler". +// Ligne de série : [-] SET | POIDS (KG) | REPS | VALIDER // // Props : -// - index (0-based, on affiche index+1) -// - setData : { weight, reps, completed } +// - index : 0-based, on affiche index+1 +// - setData : { weight, reps, completed } // - onChange : (patch) => void // - onToggle : () => void +// - onRemove : () => void | null – bouton [-] visible si non null // -function SetRow({ index, setData = {}, onChange, onToggle }) { +// Code couleur : +// - Non commencée : fond transparent +// - En cours (focus): fond violet translucide + bordure gauche violette +// - Validée : fond vert translucide + opacité réduite + +function SetRow({ index, setData = {}, onChange, onToggle, onRemove }) { + const [focused, setFocused] = useState(false); + const completed = !!setData.completed; - const weight = setData.weight !== undefined && setData.weight !== null ? String(setData.weight) : ''; - const reps = setData.reps !== undefined && setData.reps !== null ? String(setData.reps) : ''; + + // Affiche '' quand la valeur est 0 ou vide (champ "non rempli"), + // affiche la valeur réelle sinon. Vider le champ → '' en state. + const weight = setData.weight ? String(setData.weight) : ''; + const reps = setData.reps ? String(setData.reps) : ''; const handleToggle = useCallback(() => { try { Haptics.selectionAsync(); } catch (e) {} if (onToggle) onToggle(); }, [onToggle]); + const handleRemove = useCallback(() => { + try { Haptics.selectionAsync(); } catch (e) {} + if (onRemove) onRemove(); + }, [onRemove]); + + // '' → '' en state (champ vidé), sinon conversion numérique. + // On conserve '' pour ne pas afficher "0" quand le champ est effacé. const handleWeight = useCallback((text) => { if (completed) return; const value = text === '' ? '' : Number(text.replace(',', '.')) || 0; @@ -41,12 +58,43 @@ function SetRow({ index, setData = {}, onChange, onToggle }) { if (onChange) onChange({ weight: setData.weight, reps: value }); }, [onChange, setData.weight, completed]); + const rowStyle = [ + styles.row, + focused && !completed && styles.rowFocused, + completed && styles.rowDone, + ]; + return ( - + + + {/* ── [-] suppression ───────────────────────────────────────────── */} + + {onRemove ? ( + + + + ) : ( + + )} + + + {/* ── Numéro ────────────────────────────────────────────────────── */} - {index + 1} + + {index + 1} + + {/* ── Poids ─────────────────────────────────────────────────────── */} setFocused(true)} + onBlur={() => setFocused(false)} /> + {/* ── Reps ──────────────────────────────────────────────────────── */} setFocused(true)} + onBlur={() => setFocused(false)} /> + {/* ── VALIDER / Annuler ──────────────────────────────────────────── */} + ); } @@ -104,13 +159,28 @@ const styles = StyleSheet.create({ borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#23232b', }, + rowFocused: { + backgroundColor: 'rgba(110,106,240,0.07)', + borderLeftWidth: 3, + borderLeftColor: Colors.secondaryAccent, + }, rowDone: { - opacity: 0.45, + opacity: 0.65, + backgroundColor: 'rgba(34,197,94,0.06)', + }, + + colDel: { + width: 36, + alignItems: 'center', + justifyContent: 'center', + }, + delPlaceholder: { + width: 19, }, colSet: { flex: 1, alignItems: 'flex-start', - paddingLeft: 18, + paddingLeft: 4, }, colWeight: { flex: 2, @@ -126,11 +196,16 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingRight: 10, }, + setIndex: { color: Colors.textDim, fontSize: 17, fontWeight: '500', }, + setIndexFocused: { + color: Colors.secondaryAccent, + fontWeight: '700', + }, input: { color: Colors.textPrimary, fontSize: 18, @@ -140,6 +215,10 @@ const styles = StyleSheet.create({ paddingVertical: 0, paddingHorizontal: 0, }, + inputFocused: { + color: Colors.secondaryAccent, + }, + valBtn: { borderWidth: 1.5, borderColor: Colors.valid, diff --git a/front/src/components/workouts/InlineExerciseBlock.js b/front/src/components/workouts/InlineExerciseBlock.js new file mode 100644 index 0000000..ec7aaf2 --- /dev/null +++ b/front/src/components/workouts/InlineExerciseBlock.js @@ -0,0 +1,238 @@ +import React, { useCallback } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import { Colors } from '../../constants/theme'; +import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext'; +import { + primaryMuscleLabel, + secondaryMusclesLabels, + pickExerciseIcon, +} from '../../constants/exerciseFilters'; +import SetTable from './SetTable'; + +// Bloc exercice pour la vue "Voir tous les exercices" (all-in-one). +// Affiche le titre, les muscles, le SetTable compact et le bouton [+ Série]. +// La vidéo est volontairement masquée pour maximiser l'espace de saisie. +// +// Props : +// - exercise : objet exercice (avec .sets) +// - exerciseIndex : index dans state.exercises +// - onRemoveExercise : (exerciseIndex, exercise) => void +// - onReplaceExercise : (exerciseIndex) => void + +function InlineExerciseBlock({ exercise, exerciseIndex, onRemoveExercise, onReplaceExercise }) { + const { actions } = useWorkoutInProgress(); + + const title = (exercise && (exercise.name || exercise.title)) || 'Exercice'; + const icon = pickExerciseIcon(exercise); + const primary = primaryMuscleLabel(exercise); + const secondary = secondaryMusclesLabels(exercise); + const sets = (exercise && Array.isArray(exercise.sets)) ? exercise.sets : []; + const isDone = !!exercise?.done; + + const completedCount = sets.filter((s) => !!s.completed).length; + const allDone = sets.length > 0 && completedCount === sets.length; + + const handleToggle = useCallback((i) => { + actions.toggleSet(exerciseIndex, i); + }, [actions, exerciseIndex]); + + const handleChange = useCallback((i, patch) => { + const cur = sets[i] || {}; + const weight = patch.weight !== undefined ? patch.weight : cur.weight; + const reps = patch.reps !== undefined ? patch.reps : cur.reps; + actions.updateSet(exerciseIndex, i, { + weight: typeof weight === 'number' ? weight : Number(weight) || 0, + reps: typeof reps === 'number' ? reps : Number(reps) || 0, + }); + }, [actions, exerciseIndex, sets]); + + const handleAdd = useCallback(() => { + actions.addSet(exerciseIndex); + }, [actions, exerciseIndex]); + + const handleRemove = useCallback((i) => { + actions.removeSet(exerciseIndex, i); + }, [actions, exerciseIndex]); + + const showActions = useCallback(() => { + try { Haptics.selectionAsync(); } catch (_) {} + const opts = []; + if (onReplaceExercise) { + opts.push({ text: 'Remplacer', onPress: () => onReplaceExercise(exerciseIndex) }); + } + if (onRemoveExercise) { + opts.push({ + text: 'Supprimer', + style: 'destructive', + onPress: () => onRemoveExercise(exerciseIndex, exercise), + }); + } + opts.push({ text: 'Annuler', style: 'cancel' }); + Alert.alert(title, null, opts); + }, [title, onReplaceExercise, onRemoveExercise, exerciseIndex, exercise]); + + return ( + + + {/* ── En-tête exercice ──────────────────────────────────────────── */} + + + {icon} + + + + {title} + {(primary || secondary.length > 0) ? ( + + {primary ? {primary} : null} + {primary && secondary.length > 0 ? ' • ' : null} + {secondary.length > 0 ? ( + {secondary.join(', ')} + ) : null} + + ) : null} + + + {/* Compteur séries complétées */} + + {allDone + ? + : null} + + {completedCount}/{sets.length} + + + + + + + {/* ── Tableau des séries (compact = sans marge) ─────────────────── */} + + + {/* ── Ajouter une série ─────────────────────────────────────────── */} + + + Ajouter une série + + + + ); +} + +const styles = StyleSheet.create({ + block: { + marginHorizontal: 16, + marginBottom: 14, + borderRadius: 16, + borderWidth: 1, + borderColor: Colors.borderSubtle, + backgroundColor: Colors.cardDeep, + overflow: 'hidden', + }, + blockDone: { + opacity: 0.6, + borderColor: 'rgba(34,197,94,0.30)', + }, + + // ── En-tête ───────────────────────────────────────────────────────────── + blockHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 11, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.borderSubtle, + gap: 10, + }, + iconBox: { + width: 38, + height: 38, + borderRadius: 10, + backgroundColor: Colors.cardInner, + justifyContent: 'center', + alignItems: 'center', + }, + icon: { + fontSize: 20, + }, + headerText: { + flex: 1, + }, + title: { + color: Colors.textPrimary, + fontSize: 15, + fontWeight: '700', + letterSpacing: 0.1, + }, + muscles: { + marginTop: 2, + fontSize: 12, + }, + musclePrimary: { + color: Colors.primary, + fontWeight: '600', + }, + muscleSecondary: { + color: Colors.textSecondary, + }, + progressBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + backgroundColor: 'rgba(255,255,255,0.06)', + borderRadius: 10, + paddingHorizontal: 8, + paddingVertical: 4, + }, + progressBadgeDone: { + backgroundColor: 'rgba(34,197,94,0.10)', + }, + progressText: { + color: Colors.textMuted, + fontSize: 12, + fontWeight: '700', + }, + progressTextDone: { + color: Colors.valid, + }, + menuIcon: { + marginLeft: 2, + }, + + // ── Footer "+ Série" ───────────────────────────────────────────────────── + addBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 11, + gap: 5, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: Colors.borderSubtle, + }, + addBtnText: { + color: Colors.primary, + fontSize: 13, + fontWeight: '700', + }, +}); + +export default React.memo(InlineExerciseBlock); diff --git a/front/src/components/workouts/SetTable.js b/front/src/components/workouts/SetTable.js index d370c6c..9967e72 100644 --- a/front/src/components/workouts/SetTable.js +++ b/front/src/components/workouts/SetTable.js @@ -1,34 +1,82 @@ -import React from 'react'; +import React, { useCallback, useRef } from 'react'; import { View, Text, StyleSheet } from 'react-native'; import SetRow from '../cards/SetRow'; import { Colors } from '../../constants/theme'; -// Tableau pixel-perfect SET | POIDS (KG) | REPS | ✓ (maquette 2). -// Colonnes alignées via les mêmes flex/width que SetRow pour une symétrie parfaite. +// Tableau des séries : [-] SET | POIDS (KG) | REPS | VALIDER // // Props : -// - sets : array<{ weight, reps, completed }> -// - onToggle(setIndex) : tape sur la checkbox -// - onChange(setIndex, { weight, reps }) : édite poids ou reps +// - sets : array<{ weight, reps, completed }> +// - onToggle : (setIndex) => void +// - onChange : (setIndex, { weight, reps }) => void +// - onRemove : (setIndex) => void | undefined +// - compact : bool – zéro marge/radius pour InlineExerciseBlock // +// Autocomplétion : +// Quand le SET 1 a weight > 0 ET reps > 0 simultanément (i.e. les deux +// champs sont remplis), les valeurs sont copiées dans les sets suivants +// qui sont encore vides (weight = 0 ou '' ET reps = 0 ou ''). +// Ces valeurs sont RÉELLES (stockées en state), pas des placeholders. + function HeaderCell({ children, style, align = 'center' }) { return ( - + {children} ); } -export default function SetTable({ sets = [], onToggle, onChange }) { +export default function SetTable({ sets = [], onToggle, onChange, onRemove, compact = false }) { + const canRemove = !!onRemove && sets.length > 1; + + // Mémorise les valeurs précédentes auto-remplies pour propager chaque frappe + // (sans ça, dès que les sets suivants ont reps=3, isEmpty=false bloque la mise + // à jour suivante quand l'utilisateur tape "0" pour finir "30"). + const lastAutoFill = useRef({ weight: 0, reps: 0 }); + + const handleChange = useCallback((i, patch) => { + if (!onChange) return; + onChange(i, patch); + + if (i !== 0) return; + + const newWeight = Number(patch.weight) || 0; + const newReps = Number(patch.reps) || 0; + if (newWeight <= 0 || newReps <= 0) return; + + const prev = lastAutoFill.current; + + sets.forEach((s, idx) => { + if (idx === 0 || s.completed) return; + const sw = Number(s.weight) || 0; + const sr = Number(s.reps) || 0; + const isEmpty = sw === 0 && sr === 0; + // Aussi mettre à jour si ce set a été auto-rempli au keystroke précédent + const wasAutoFilled = sw === prev.weight && sr === prev.reps && (prev.weight > 0 || prev.reps > 0); + if (!isEmpty && !wasAutoFilled) return; + onChange(idx, { weight: newWeight, reps: newReps }); + }); + + lastAutoFill.current = { weight: newWeight, reps: newReps }; + }, [onChange, sets]); + return ( - + + + {/* ── En-tête ──────────────────────────────────────────────────── */} + SET POIDS (KG) REPS + {/* ── Lignes ───────────────────────────────────────────────────── */} {sets.length === 0 ? ( Aucune série pour cet exercice @@ -40,10 +88,12 @@ export default function SetTable({ sets = [], onToggle, onChange }) { index={i} setData={s} onToggle={() => onToggle && onToggle(i)} - onChange={(patch) => onChange && onChange(i, patch)} + onChange={(patch) => handleChange(i, patch)} + onRemove={canRemove ? () => onRemove(i) : null} /> )) )} + ); } @@ -57,6 +107,12 @@ const styles = StyleSheet.create({ backgroundColor: 'transparent', overflow: 'hidden', }, + tableCompact: { + marginHorizontal: 0, + borderRadius: 0, + borderWidth: 0, + }, + headerRow: { flexDirection: 'row', alignItems: 'center', @@ -74,9 +130,14 @@ const styles = StyleSheet.create({ fontWeight: '700', letterSpacing: 1, }, + + // Colonnes — alignées avec celles de SetRow + colDel: { + width: 36, + }, colSet: { flex: 1, - paddingLeft: 18, + paddingLeft: 4, }, colWeight: { flex: 2, @@ -89,6 +150,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + empty: { paddingVertical: 22, alignItems: 'center', diff --git a/front/src/hooks/useWorkoutState.js b/front/src/hooks/useWorkoutState.js index 8ea44ca..0afd2e0 100644 --- a/front/src/hooks/useWorkoutState.js +++ b/front/src/hooks/useWorkoutState.js @@ -127,11 +127,21 @@ function reducer(state, action) { } case ACTIONS.ADD_EXERCISE: { - const { exercise } = action.payload || {}; + const { exercise, defaultSetsCount } = action.payload || {}; if (!exercise) return state; - const sets = Array.isArray(exercise.sets) && exercise.sets.length > 0 - ? exercise.sets - : [{}, {}, {}, {}]; + const count = Number.isInteger(defaultSetsCount) && defaultSetsCount >= 1 ? defaultSetsCount : 4; + // Les exos du catalogue arrivent avec sets:[{},{},{}] (objets vides, pas de weight/reps). + // On ne les utilise que s'ils ont au moins un set avec des données réelles. + const hasMeaningfulSets = Array.isArray(exercise.sets) && + exercise.sets.length > 0 && + exercise.sets.some((s) => s && (s.weight !== undefined || s.reps !== undefined)); + const sets = hasMeaningfulSets + ? exercise.sets.map((s) => ({ + weight: s.weight ?? 0, + reps: s.reps ?? 0, + completed: s.completed ?? false, + })) + : Array.from({ length: count }, () => ({ weight: 0, reps: 0, completed: false })); const exercises = [ ...state.exercises, { ...exercise, sets, notes: exercise.notes || '', groupId: exercise.groupId || null }, @@ -306,8 +316,8 @@ export default function useWorkoutState(initial = {}) { debouncedSave(); }, [debouncedSave]); - const addExercise = useCallback((exercise) => { - dispatch({ type: ACTIONS.ADD_EXERCISE, payload: { exercise } }); + const addExercise = useCallback((exercise, defaultSetsCount) => { + dispatch({ type: ACTIONS.ADD_EXERCISE, payload: { exercise, defaultSetsCount } }); debouncedSave(); }, [debouncedSave]); diff --git a/front/src/screens/Profile/EditProfileScreen.js b/front/src/screens/Profile/EditProfileScreen.js index 404552a..6e2d912 100644 --- a/front/src/screens/Profile/EditProfileScreen.js +++ b/front/src/screens/Profile/EditProfileScreen.js @@ -8,6 +8,7 @@ import { TextInput, StatusBar, } from 'react-native'; + import { Colors } from '../../constants/theme'; import API from '../../api/api'; import { useUser } from '../../context/UserContext'; @@ -23,10 +24,16 @@ const SELECTIONS = { objectif: ['Prise de masse', 'Perte de poids', 'Entretien', 'Force'], }; +// L'API stocke objectif en minuscules ("prise de masse"), les chips utilisent des majuscules. +// Cette fonction trouve la valeur affichable correspondante. +function normalizeObjectif(val) { + if (!val) return ''; + return SELECTIONS.objectif.find((o) => o.toLowerCase() === val.toLowerCase()) || val; +} + export default function EditProfileScreen({ navigation }) { const { user, refetch: refetchUser } = useUser(); const { showToast } = useToast(); - const [formData, setFormData] = useState({ name: user?.name || '', bio: user?.bio || '', @@ -34,7 +41,7 @@ export default function EditProfileScreen({ navigation }) { taille: user?.taille?.toString() || '', poidsCible: user?.poidsCible?.toString() || '', sexe: user?.sexe || '', - objectif: user?.objectif || '', + objectif: normalizeObjectif(user?.objectif), niveauSportif: user?.niveauSportif || 'Débutant', rythme: user?.rythme?.toString() || '', }); @@ -49,7 +56,7 @@ export default function EditProfileScreen({ navigation }) { taille: user.taille?.toString() || '', poidsCible: user.poidsCible?.toString() || '', sexe: user.sexe || 'H', - objectif: user.objectif || 'Entretien', + objectif: normalizeObjectif(user.objectif) || 'Entretien', niveauSportif: user.niveauSportif || 'Débutant', rythme: user.rythme?.toString() || '', }); @@ -78,15 +85,24 @@ export default function EditProfileScreen({ navigation }) { setErrors({}); setLoading(true); - // bio excluded from payload — backend schema doesn't allow it - const { bio: _bio, ...restFormData } = formData; + // On ne transmet que les champs renseignés : poids/taille/rythme n'ont pas + // .allow(null) dans le schema Joi → les envoyer à null déclenche une 400. + // objectif : le backend attend les minuscules ("prise de masse"), le front + // affiche des majuscules → on normalise avant envoi. + const parsedPoids = parseFloat(formData.poids?.replace(',', '.')) || null; + const parsedTaille = parseInt(formData.taille, 10) || null; + const parsedPoidsCible = parseFloat(formData.poidsCible?.replace(',', '.')) || null; + const parsedRythme = parseInt(formData.rythme, 10) || null; + const payload = { - ...restFormData, - name: nameVal, - poids: parseFloat(formData.poids?.replace(',', '.')) || null, - taille: parseInt(formData.taille) || null, - poidsCible: parseFloat(formData.poidsCible?.replace(',', '.')) || null, - rythme: parseInt(formData.rythme) || null, + name: nameVal, + ...(formData.sexe && { sexe: formData.sexe }), + ...(formData.niveauSportif && { niveauSportif: formData.niveauSportif }), + ...(formData.objectif && { objectif: formData.objectif.toLowerCase() }), + ...(parsedPoids !== null && { poids: parsedPoids }), + ...(parsedTaille !== null && { taille: parsedTaille }), + ...(parsedPoidsCible !== null && { poidsCible: parsedPoidsCible }), + ...(parsedRythme !== null && { rythme: parsedRythme }), }; try { const res = await API.put('/users/me', payload); diff --git a/front/src/screens/Workouts/ExerciseDetailScreen.js b/front/src/screens/Workouts/ExerciseDetailScreen.js index 9cbeb23..6aaee82 100644 --- a/front/src/screens/Workouts/ExerciseDetailScreen.js +++ b/front/src/screens/Workouts/ExerciseDetailScreen.js @@ -74,6 +74,11 @@ export default function ExerciseDetailScreen({ route, navigation }) { actions.addSet(exerciseIndex); }, [actions, exerciseIndex]); + const handleRemove = useCallback((i) => { + if (exerciseIndex < 0 || sets.length <= 1) return; + actions.removeSet(exerciseIndex, i); + }, [actions, exerciseIndex, sets.length]); + const handleNotes = useCallback((text) => { if (exerciseIndex < 0) return; actions.updateExerciseNotes(exerciseIndex, text); @@ -161,6 +166,7 @@ export default function ExerciseDetailScreen({ route, navigation }) { sets={sets} onToggle={handleToggle} onChange={handleChange} + onRemove={handleRemove} /> { + if (item.type === 'superset') { + return ( + + {item.exercises.map((ex, idx) => ( + + ))} + + ); + } + return ( + + ); + }; + const renderExercise = (ex, sourceIndex, opts = {}) => { const isDone = !!(ex && ex.done); return ( @@ -373,16 +403,37 @@ export default function WorkoutScreen({ route, navigation }) { {/* ── Filtres ── */} + {/* ── Toggle vue globale ── */} + {sourceExercises.length > 0 && ( + + setAllInOne((v) => !v)} + activeOpacity={0.8} + > + + + {allInOne ? 'Vue détaillée' : 'Voir tous les exercices'} + + + + )} + {/* ── Liste des exercices — flex:1 pour occuper tout l'espace disponible ── */} {displayItems.length > 0 ? ( item.key} - renderItem={renderItem} + renderItem={allInOne ? renderItemAllInOne : renderItem} ListFooterComponent={renderFooter} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" /> ) : ( @@ -592,4 +643,34 @@ const styles = StyleSheet.create({ fontWeight: '900', letterSpacing: 1.2, }, + + // ── Bascule vue globale ──────────────────────────────────────────────── + viewToggleBar: { + paddingHorizontal: 20, + paddingVertical: 6, + alignItems: 'flex-end', + }, + viewToggleBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 14, + borderWidth: 1, + borderColor: Colors.borderDim, + backgroundColor: 'rgba(255,255,255,0.04)', + }, + viewToggleBtnActive: { + borderColor: 'rgba(110,106,240,0.45)', + backgroundColor: 'rgba(110,106,240,0.09)', + }, + viewToggleText: { + color: Colors.textMuted, + fontSize: 12, + fontWeight: '700', + }, + viewToggleTextActive: { + color: Colors.secondaryAccent, + }, }); From 06bde561b4c0d971d6a94183fdcc2a76d66f84da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 11:09:47 +0200 Subject: [PATCH 02/31] fix: resolution du bug de repetition des notifications et ajout des declencheurs contextuels v2 --- .DS_Store | Bin 0 -> 6148 bytes back/package-lock.json | 2284 ++-- front/package-lock.json | 11257 ++++++++------------ front/src/navigation/index.js | 11 +- front/src/services/notificationService.js | 197 +- 5 files changed, 5912 insertions(+), 7837 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..56f9ae74dacf8e846650e39b2f5d82c16db394be GIT binary patch literal 6148 zcmeHKPfrs;6n_Inwji=Vi%2xt(2EHK5r~l(Ls=kN|3O%SMZmh-Zp+GcrrF(6k&yJP zi3g8<06%~yj$S-^_29)X;K7queX}#=PtmIovoD$Xy_xsk%)a05%Wfz@V5_Fo*hG&p!Ur}kN9eyim1C!J+3^K`drXPrEwhV#=2d z$#2OnJU#4M-l?EZ;;yBsJbgkq$g@t0#w}b2veKn_c`+1MK?jrM@z+rTC-A9r$ zfnCnfBEuQCk>G_2H~A~J=Odno$*!87XVQ|b?`*iHWxEShXF?)ni7a|HA1R#M zsF-<&mQvF{1dFNkVnewq6ctiJ!4o6V&?J)7M@tL!Wy+`>kEhc2rAdKdn1Op>z$Vn- z4ZMT*un(W$D}0BaBu4tl5E&+u2Xc6rn_3F^If55D|{{wDhkF6yZR^nQ&uAuHnDj|BnYnt%!k$f&YpD5}PyTbWBN}ttXP>XRU_i85Si0W6`QP2&n;T4G%w_Mk*R0wE1i6*2Hn8TbtbRQB8e literal 0 HcmV?d00001 diff --git a/back/package-lock.json b/back/package-lock.json index c159866..1089450 100644 --- a/back/package-lock.json +++ b/back/package-lock.json @@ -33,13 +33,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -48,9 +48,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -58,21 +58,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -88,25 +88,15 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -116,14 +106,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -132,20 +122,10 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -153,29 +133,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -185,9 +165,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -195,9 +175,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -205,9 +185,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -215,9 +195,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -225,27 +205,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -310,13 +290,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -352,13 +332,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -478,13 +458,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -494,33 +474,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -528,14 +508,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -549,21 +529,21 @@ "license": "MIT" }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -572,9 +552,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -625,76 +605,37 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -726,9 +667,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -736,13 +677,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -780,9 +721,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", - "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", "license": "BSD-3-Clause", "engines": { "node": ">=14.0.0" @@ -798,29 +739,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -941,9 +896,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -951,17 +906,17 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -969,39 +924,39 @@ } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -1017,9 +972,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -1027,39 +982,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1070,18 +1025,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1098,62 +1053,62 @@ } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.0.1" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1171,9 +1126,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1184,13 +1139,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1215,14 +1170,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1231,15 +1186,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -1247,24 +1202,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -1274,14 +1228,14 @@ } }, "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -1343,25 +1297,31 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz", - "integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==", + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz", + "integrity": "sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==", "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@noble/hashes": { @@ -1399,22 +1359,22 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" } }, "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "dev": true, "license": "MIT" }, @@ -1429,9 +1389,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1445,9 +1405,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1508,9 +1468,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1549,13 +1509,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/stack-utils": { @@ -1598,16 +1558,16 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", "dev": true, "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", "cpu": [ "arm" ], @@ -1619,9 +1579,9 @@ ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", "cpu": [ "arm64" ], @@ -1633,9 +1593,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "cpu": [ "arm64" ], @@ -1647,9 +1607,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", "cpu": [ "x64" ], @@ -1661,9 +1621,9 @@ ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", "cpu": [ "x64" ], @@ -1675,9 +1635,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", "cpu": [ "arm" ], @@ -1689,9 +1649,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", "cpu": [ "arm" ], @@ -1703,13 +1663,16 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1717,13 +1680,50 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1731,13 +1731,16 @@ ] }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1745,13 +1748,16 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1759,13 +1765,16 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1773,13 +1782,16 @@ ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1787,13 +1799,16 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1801,23 +1816,40 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", "cpu": [ "wasm32" ], @@ -1825,16 +1857,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", "cpu": [ "arm64" ], @@ -1846,9 +1880,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", "cpu": [ "ia32" ], @@ -1860,9 +1894,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], @@ -1887,9 +1921,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -1910,9 +1944,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1985,6 +2019,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -2010,16 +2057,16 @@ "license": "MIT" }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -2052,9 +2099,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, "license": "MIT", "dependencies": { @@ -2092,13 +2139,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -2109,16 +2156,19 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2174,21 +2224,34 @@ } }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -2198,14 +2261,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -2222,9 +2287,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -2242,11 +2307,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -2266,9 +2331,9 @@ } }, "node_modules/bson": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.0.0.tgz", - "integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.1.tgz", + "integrity": "sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==", "license": "Apache-2.0", "engines": { "node": ">=20.19.0" @@ -2346,9 +2411,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001772", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001772.tgz", - "integrity": "sha512-mIwLZICj+ntVTw4BT2zfp+yu/AqV6GMKfJVJMx3MwPxs+uk/uj2GLl2dH8LQbjiLDX66amCga5nKFyDgRR43kg==", + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", "dev": true, "funding": [ { @@ -2383,29 +2448,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -2441,6 +2483,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -2546,6 +2601,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2661,9 +2726,9 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -2715,9 +2780,9 @@ "license": "MIT" }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -2725,6 +2790,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -2760,9 +2829,9 @@ } }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2832,9 +2901,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2880,9 +2949,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.384", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", + "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==", "dev": true, "license": "ISC" }, @@ -2957,9 +3026,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3014,29 +3083,32 @@ } }, "node_modules/eslint": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", - "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -3047,7 +3119,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3070,9 +3142,9 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3101,68 +3173,16 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -3289,18 +3309,18 @@ } }, "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3350,12 +3370,12 @@ } }, "node_modules/express-validator": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", - "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", "license": "MIT", "dependencies": { - "lodash": "^4.17.21", + "lodash": "^4.18.1", "validator": "~13.15.23" }, "engines": { @@ -3479,9 +3499,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3503,17 +3523,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3725,49 +3745,43 @@ } }, "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 6" + "node": ">=10.13.0" } }, "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "balanced-match": "^1.0.0" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", - "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3777,9 +3791,9 @@ } }, "node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -3809,13 +3823,13 @@ "license": "ISC" }, "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/has-symbols": { @@ -3847,9 +3861,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3859,12 +3873,15 @@ } }, "node_modules/helmet": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", - "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", "license": "MIT", "engines": { "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" } }, "node_modules/html-escaper": { @@ -3921,9 +3938,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -4041,13 +4058,19 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-generator-fn": { @@ -4136,6 +4159,19 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -4151,29 +4187,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/istanbul-lib-source-maps": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", @@ -4220,16 +4233,16 @@ } }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "jest-cli": "30.4.2" }, "bin": { "jest": "bin/jest.js" @@ -4247,14 +4260,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "p-limit": "^3.1.0" }, "engines": { @@ -4262,29 +4275,29 @@ } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -4294,21 +4307,21 @@ } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "bin": { @@ -4327,34 +4340,33 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -4379,25 +4391,25 @@ } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, "license": "MIT", "dependencies": { @@ -4408,57 +4420,57 @@ } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "jest-util": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", "walker": "^1.0.8" }, "engines": { @@ -4469,49 +4481,50 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -4520,15 +4533,15 @@ } }, "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4553,9 +4566,9 @@ } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, "license": "MIT", "engines": { @@ -4563,18 +4576,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -4583,46 +4596,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -4631,32 +4644,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -4665,9 +4678,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, "license": "MIT", "dependencies": { @@ -4676,20 +4689,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.2.0", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -4697,50 +4710,50 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4760,19 +4773,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "string-length": "^4.0.2" }, "engines": { @@ -4780,15 +4793,15 @@ } }, "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -4796,16 +4809,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -4823,9 +4826,9 @@ } }, "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", "license": "BSD-3-Clause", "dependencies": { "@hapi/address": "^5.1.1", @@ -4834,7 +4837,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -4848,9 +4851,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -4937,6 +4940,18 @@ "npm": ">=6" } }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4959,9 +4974,9 @@ } }, "node_modules/kareem": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.0.0.tgz", - "integrity": "sha512-RKhaOBSPN8L7y4yAgNhDT2602G5FD6QbOIISbjN9D6mjHPeqeg7K+EB5IGSU5o81/X2Gzm3ICnAvQW3x3OP8HA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.3.0.tgz", + "integrity": "sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -5009,9 +5024,9 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "license": "MIT", "dependencies": { @@ -5033,23 +5048,10 @@ "yaml": "^2.9.0" } }, - "node_modules/lint-staged/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", + "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", "dev": true, "license": "MIT", "dependencies": { @@ -5128,9 +5130,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.includes": { @@ -5231,22 +5233,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update/node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -5326,6 +5312,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -5389,20 +5388,6 @@ "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -5465,16 +5450,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { @@ -5488,13 +5476,13 @@ } }, "node_modules/mongodb": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", - "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz", + "integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.0.0", + "bson": "^7.2.0", "mongodb-connection-string-url": "^7.0.0" }, "engines": { @@ -5534,9 +5522,9 @@ } }, "node_modules/mongodb-connection-string-url": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.0.tgz", - "integrity": "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", "license": "Apache-2.0", "dependencies": { "@types/whatwg-url": "^13.0.0", @@ -5547,13 +5535,14 @@ } }, "node_modules/mongoose": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.0.1.tgz", - "integrity": "sha512-aHPfQx2YX5UwAmMVud7OD4lIz9AEO4jI+oDnRh3lPZq9lrKTiHmOzszVffDMyQHXvrf4NXsJ34kpmAhyYAZGbw==", + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.7.3.tgz", + "integrity": "sha512-9DsD4sq0tL9IKyfttZ6qn74KXBcD4lxDTzGXoAeOLMnDFFp1gqc5oc4s2BiMPbetJrLwvVylfyR4cqLst2pasQ==", "license": "MIT", "dependencies": { - "kareem": "3.0.0", - "mongodb": "~7.0", + "@standard-schema/spec": "^1.1.0", + "kareem": "3.3.0", + "mongodb": "~7.2", "mpath": "0.9.0", "mquery": "6.0.0", "ms": "2.1.3", @@ -5568,19 +5557,23 @@ } }, "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", - "on-finished": "~2.3.0", + "on-finished": "~2.4.1", "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/morgan/node_modules/debug": { @@ -5598,18 +5591,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -5667,9 +5648,9 @@ } }, "node_modules/node-addon-api": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", - "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -5694,32 +5675,35 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nodemailer": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", - "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, "node_modules/nodemon": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", - "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", @@ -5738,6 +5722,42 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5978,9 +5998,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -5995,13 +6015,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -6097,15 +6117,16 @@ } }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6171,12 +6192,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6186,12 +6208,16 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { @@ -6209,13 +6235,22 @@ "node": ">= 0.10" } }, - "node_modules/react-is": { + "node_modules/react-is-18": { + "name": "react-is", "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6229,6 +6264,19 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6345,43 +6393,45 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -6391,6 +6441,10 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -6423,14 +6477,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -6442,13 +6496,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -6526,6 +6580,19 @@ "node": ">=10" } }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -6566,22 +6633,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6749,6 +6800,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6763,13 +6824,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -6872,26 +6933,26 @@ } }, "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -6915,6 +6976,24 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6937,6 +7016,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -7043,17 +7135,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/undefsafe": { @@ -7064,9 +7173,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -7080,38 +7189,41 @@ } }, "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.3.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "node_modules/update-browserslist-db": { @@ -7171,9 +7283,9 @@ } }, "node_modules/validator": { - "version": "13.15.23", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", - "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -7300,6 +7412,16 @@ "dev": true, "license": "MIT" }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7396,9 +7518,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { @@ -7441,6 +7563,16 @@ "dev": true, "license": "MIT" }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", diff --git a/front/package-lock.json b/front/package-lock.json index 3ed92cc..71a2e72 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -44,9 +44,9 @@ } }, "node_modules/@0no-co/graphql.web": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", - "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.2.tgz", + "integrity": "sha512-Q1+pRlLhE31GOY/2c9BAEnFTNxO7Awtc6fhhEDlxyCBQ2N0IhD32cPVvPChrK9mwBNSgRdW/sF1kd2e0ojHj1Q==", "license": "MIT", "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" @@ -58,12 +58,17 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/highlight": "^7.10.4" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { @@ -76,20 +81,20 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -105,29 +110,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", @@ -172,15 +154,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", @@ -202,15 +175,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", @@ -228,15 +192,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-define-polyfill-provider": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", @@ -415,13 +370,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -442,6 +397,77 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", @@ -559,14 +585,14 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz", - "integrity": "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-decorators": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -576,12 +602,12 @@ } }, "node_modules/@babel/plugin-proposal-export-default-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", - "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -655,12 +681,12 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", - "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -682,12 +708,12 @@ } }, "node_modules/@babel/plugin-syntax-export-default-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.27.1.tgz", - "integrity": "sha512-eBC/3KSekshx19+N40MzjWqJd7KTEdOoLesAfa4IDFI8eRz5a47i5Oszus6zG/cwIXN63YhgLOMSSNJx49sENg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -697,12 +723,12 @@ } }, "node_modules/@babel/plugin-syntax-flow": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", - "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -767,12 +793,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -884,12 +910,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1194,13 +1220,13 @@ } }, "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", - "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-flow": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1567,12 +1593,12 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1582,16 +1608,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", - "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1601,12 +1627,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", "license": "MIT", "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" + "@babel/plugin-transform-react-jsx": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1616,12 +1642,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1631,12 +1657,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1646,13 +1672,13 @@ } }, "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1710,13 +1736,13 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.5.tgz", - "integrity": "sha512-20NUVgOrinudkIBzQ2bNxP08YpKprUkRTiRSd2/Z5GOdPImJGkoN4Z7IQe1T5AdyKI1i5L6RBmluqdSzvaq9/w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", @@ -1729,13 +1755,17 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/@babel/plugin-transform-shorthand-properties": { @@ -1817,16 +1847,16 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz", - "integrity": "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1987,30 +2017,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", - "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -2027,17 +2033,17 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", - "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.29.7.tgz", + "integrity": "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.28.0", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-react-display-name": "^7.29.7", + "@babel/plugin-transform-react-jsx": "^7.29.7", + "@babel/plugin-transform-react-jsx-development": "^7.29.7", + "@babel/plugin-transform-react-pure-annotations": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2047,16 +2053,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2066,9 +2072,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2088,20 +2094,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/traverse": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", @@ -2122,59 +2114,31 @@ }, "node_modules/@babel/traverse--for-generate-function-map": { "name": "@babel/traverse", - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse--for-generate-function-map/node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2199,14 +2163,165 @@ "node": ">=0.8.0" } }, + "node_modules/@expo/cli": { + "version": "54.0.25", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz", + "integrity": "sha512-WnUqIb8oMBhtwSfIqdCHCzcaDIpLNXItRVd5miuvWi4GO0SGo89PSsAkbVJ+LJgcaY+v5rbgMELJS9I/CqOulA==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.8", + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.0.8", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.16", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "~54.0.16", + "@expo/osascript": "^2.3.8", + "@expo/package-manager": "^1.9.10", + "@expo/plist": "^0.4.9", + "@expo/prebuild-config": "^54.0.8", + "@expo/schema-utils": "^0.1.8", + "@expo/spawn-async": "^1.7.2", + "@expo/ws-tunnel": "^1.0.1", + "@expo/xcpretty": "^4.3.0", + "@react-native/dev-middleware": "0.81.5", + "@urql/core": "^5.0.6", + "@urql/exchange-retry": "^1.3.0", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "better-opn": "~3.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "env-editor": "^0.4.1", + "expo-server": "^1.0.7", + "freeport-async": "^2.0.0", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "minimatch": "^9.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.3", + "pretty-bytes": "^5.6.0", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "qrcode-terminal": "0.11.0", + "require-from-string": "^2.0.2", + "requireg": "^0.2.2", + "resolve": "^1.22.2", + "resolve-from": "^5.0.0", + "resolve.exports": "^2.0.3", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "source-map-support": "~0.5.21", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "tar": "^7.5.2", + "terminal-link": "^2.1.1", + "undici": "^6.18.2", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1" + }, + "bin": { + "expo-internal": "build/bin/cli" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/cli/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/cli/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/cli/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/code-signing-certificates": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz", - "integrity": "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw==", + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", "license": "MIT", "dependencies": { - "node-forge": "^1.2.1", - "nullthrows": "^1.1.1" + "node-forge": "^1.3.3" } }, "node_modules/@expo/config": { @@ -2252,81 +2367,169 @@ "xml2js": "0.6.0" } }, - "node_modules/@expo/config-plugins/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@expo/config-plugins/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/config-plugins/node_modules/@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/config-plugins/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/config-plugins/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", + "node_modules/@expo/config-plugins/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/config-plugins/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=7.0.0" + "node": ">=10" } }, - "node_modules/@expo/config-plugins/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/@expo/config-types": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", "license": "MIT" }, - "node_modules/@expo/config-plugins/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@expo/config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/config-plugins/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@expo/config/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/config-types": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", - "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", - "license": "MIT" + "node_modules/@expo/config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, "node_modules/@expo/devcert": { "version": "1.2.1", @@ -2368,484 +2571,380 @@ } } }, - "node_modules/@expo/devtools/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@expo/env": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.11.tgz", + "integrity": "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/devtools/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" } }, - "node_modules/@expo/devtools/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@expo/fingerprint": { + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz", + "integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "p-limit": "^3.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" }, - "engines": { - "node": ">=7.0.0" + "bin": { + "fingerprint": "bin/cli.js" } }, - "node_modules/@expo/devtools/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/devtools/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@expo/fingerprint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/devtools/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@expo/fingerprint/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/env": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz", - "integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==", - "license": "MIT", + "node_modules/@expo/fingerprint/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/env/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", + "node_modules/@expo/fingerprint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "color-convert": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/env/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@expo/env/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@expo/image-utils": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.14.tgz", + "integrity": "sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@expo/require-utils": "^55.0.5", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=7.0.0" + "node": ">=10" } }, - "node_modules/@expo/env/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/env/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" } }, - "node_modules/@expo/env/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@expo/metro": { + "version": "54.2.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", + "integrity": "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3" } }, - "node_modules/@expo/fingerprint": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz", - "integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==", + "node_modules/@expo/metro-config": { + "version": "54.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.16.tgz", + "integrity": "sha512-3LLb9ZQl0VlqSlsalJ7+CYjfz60PBoSDHvpE1UF71aTM1Nx0Vb4LhXo7bCCC+PYP9q/GPB58LLbIROQ8PjKX2w==", "license": "MIT", "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8", + "@expo/json-file": "~10.0.16", + "@expo/metro": "~54.2.0", "@expo/spawn-async": "^1.7.2", - "arg": "^5.0.2", - "chalk": "^4.1.2", - "debug": "^4.3.4", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^13.0.0", - "ignore": "^5.3.1", - "minimatch": "^9.0.0", - "p-limit": "^3.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.6.0" - }, - "bin": { - "fingerprint": "bin/cli.js" - } - }, - "node_modules/@expo/fingerprint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" + "hermes-parser": "^0.29.1", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.3", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "expo": "*" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependenciesMeta": { + "expo": { + "optional": true + } } }, - "node_modules/@expo/fingerprint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@expo/metro-config/node_modules/@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" } }, - "node_modules/@expo/fingerprint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@expo/metro-config/node_modules/@expo/json-file/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "@babel/highlight": "^7.10.4" } }, - "node_modules/@expo/fingerprint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/fingerprint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@expo/metro-config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/fingerprint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@expo/metro-config/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/image-utils": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.8.tgz", - "integrity": "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA==", - "license": "MIT", - "dependencies": { - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "resolve-from": "^5.0.0", - "resolve-global": "^1.0.0", - "semver": "^7.6.0", - "temp-dir": "~2.0.0", - "unique-string": "~2.0.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@expo/image-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", + "node_modules/@expo/metro-config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "color-convert": "^2.0.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/image-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", + "node_modules/@expo/metro-config/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@expo/image-utils/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@expo/image-utils/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/image-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/image-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/json-file": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", - "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" - } - }, - "node_modules/@expo/metro": { - "version": "54.1.0", - "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.1.0.tgz", - "integrity": "sha512-MgdeRNT/LH0v1wcO0TZp9Qn8zEF0X2ACI0wliPtv5kXVbXWI+yK9GyrstwLAiTXlULKVIg3HVSCCvmLu0M3tnw==", - "license": "MIT", - "dependencies": { - "metro": "0.83.2", - "metro-babel-transformer": "0.83.2", - "metro-cache": "0.83.2", - "metro-cache-key": "0.83.2", - "metro-config": "0.83.2", - "metro-core": "0.83.2", - "metro-file-map": "0.83.2", - "metro-resolver": "0.83.2", - "metro-runtime": "0.83.2", - "metro-source-map": "0.83.2", - "metro-transform-plugins": "0.83.2", - "metro-transform-worker": "0.83.2" - } - }, - "node_modules/@expo/metro-runtime": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-4.0.1.tgz", - "integrity": "sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw==", + "node_modules/@expo/metro-runtime": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-4.0.1.tgz", + "integrity": "sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw==", "license": "MIT", "peerDependencies": { "react-native": "*" } }, "node_modules/@expo/osascript": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz", - "integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz", + "integrity": "sha512-wKIXL8UtbuX4KwavPasIW3CUcgTbYfjzLcgUhjyKUAYDEqMaf6gmU1bqz3ffBPTokmX+G8/vFG1ZuI9etQWukA==", "license": "MIT", "dependencies": { - "@expo/spawn-async": "^1.7.2", - "exec-async": "^2.2.0" + "@expo/spawn-async": "^1.8.0" }, "engines": { "node": ">=12" } }, "node_modules/@expo/package-manager": { - "version": "1.9.9", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.9.tgz", - "integrity": "sha512-Nv5THOwXzPprMJwbnXU01iXSrCp3vJqly9M4EJ2GkKko9Ifer2ucpg7x6OUsE09/lw+npaoUnHMXwkw7gcKxlg==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.0.tgz", + "integrity": "sha512-s3W3eZafJDEyVL7W/jxj2Nz3eONKxSCU604S5xj8ijrVaRz83x0DnZznLf/UXQEI1w+FyibH68nHeQyk767b1A==", "license": "MIT", "dependencies": { - "@expo/json-file": "^10.0.8", - "@expo/spawn-async": "^1.7.2", + "@expo/json-file": "^11.0.0", + "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, - "node_modules/@expo/package-manager/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@expo/package-manager/node_modules/@expo/json-file": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.0.tgz", + "integrity": "sha512-pHJCETqFL5x5BzNV6cEPwjwuECgGmnl0bNmfHIJ6LM1tlh2eVXi5HEdit3zby/JO/B8Otk5cgcqtJXgvvUat3A==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" } }, - "node_modules/@expo/package-manager/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@expo/plist": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz", + "integrity": "sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" } }, - "node_modules/@expo/package-manager/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@expo/prebuild-config": { + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", + "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@expo/package-manager/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@expo/package-manager/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "peerDependencies": { + "expo": "*" } }, - "node_modules/@expo/package-manager/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/@expo/plist": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", - "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", + "node_modules/@expo/require-utils": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", + "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", "license": "MIT", "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@expo/schema-utils": { @@ -2861,12 +2960,12 @@ "license": "MIT" }, "node_modules/@expo/spawn-async": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", - "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3" + "cross-spawn": "^7.0.6" }, "engines": { "node": ">=12" @@ -2879,9 +2978,9 @@ "license": "MIT" }, "node_modules/@expo/vector-icons": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.3.tgz", - "integrity": "sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", "license": "MIT", "peerDependencies": { "expo-font": ">=14.0.4", @@ -2896,365 +2995,283 @@ "license": "MIT" }, "node_modules/@expo/xcpretty": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.3.2.tgz", - "integrity": "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", + "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", "license": "BSD-3-Clause", "dependencies": { - "@babel/code-frame": "7.10.4", + "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", - "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, - "node_modules/@expo/xcpretty/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@expo/xcpretty/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/@expo/xcpretty/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@expo/xcpretty/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" + "argparse": "^2.0.1" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@expo/xcpretty/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", "dependencies": { - "color-name": "~1.1.4" + "minipass": "^7.0.4" }, "engines": { - "node": ">=7.0.0" + "node": ">=18.0.0" } }, - "node_modules/@expo/xcpretty/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/@expo/xcpretty/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "license": "MIT", + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/@expo/xcpretty/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@expo/xcpretty/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@expo/xcpretty/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@expo/xcpretty/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=10" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@expo/xcpretty/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@ide/backoff": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", - "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", - "license": "MIT" - }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", "license": "MIT", "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@isaacs/ttlcache": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", - "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "@jest/types": "^29.6.3" }, "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "license": "MIT", - "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console": { + "node_modules/@jest/environment": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "license": "MIT", "dependencies": { + "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" + "jest-mock": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "jest-get-type": "^29.6.3" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/console/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jest/console/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/core": { + "node_modules/@jest/reporters": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", "dev": true, "license": "MIT", "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", "@jest/test-result": "^29.7.0", "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", "@types/node": "*", - "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", - "ci-info": "^3.2.0", + "collect-v8-coverage": "^1.0.0", "exit": "^0.1.2", + "glob": "^7.1.3", "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", + "jest-worker": "^29.7.0", "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -3268,4540 +3285,2969 @@ } } }, - "node_modules/@jest/core/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "color-convert": "^2.0.1" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=10" } }, - "node_modules/@jest/core/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@jest/reporters/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/core/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" } }, - "node_modules/@jest/core/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@sinclair/typebox": "^0.27.8" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/core/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jest/core/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/core/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/create-cache-key-function": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", - "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3" + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/environment": { + "node_modules/@jest/test-result": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", + "@jest/console": "^29.7.0", "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/expect": { + "node_modules/@jest/test-sequencer": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", "dev": true, "license": "MIT", "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/expect-utils": { + "node_modules/@jest/transform": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3" + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jest/reporters/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=6.0.0" } }, - "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, - "node_modules/@jest/reporters/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, - "node_modules/@jest/reporters/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jest/reporters/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jest/reporters/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" + "merge-options": "^3.0.4" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, - "node_modules/@jest/reporters/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "node_modules/@react-native/assets-registry": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", + "integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 20.19.4" } }, - "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", + "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", + "license": "MIT", "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.81.5" }, "engines": { - "node": ">=10" + "node": ">= 20.19.4" } }, - "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", + "node_modules/@react-native/babel-preset": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", + "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.81.5", + "babel-plugin-syntax-hermes-parser": "0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" }, "engines": { - "node": "*" + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" } }, - "node_modules/@jest/reporters/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/@react-native/codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", + "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.29.1", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" }, "engines": { - "node": ">=8" + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "node_modules/@react-native/community-cli-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz", + "integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==", "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@react-native/dev-middleware": "0.81.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.83.1", + "metro-config": "^0.83.1", + "metro-core": "^0.83.1", + "semver": "^7.1.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "*" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } } }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", + "integrity": "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==", + "license": "BSD-3-Clause", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 20.19.4" } }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, + "node_modules/@react-native/dev-middleware": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz", + "integrity": "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==", "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/transform/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/transform/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/transform/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@jest/transform/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/transform/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/types/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/@jest/types/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@react-native-async-storage/async-storage": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", - "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", - "license": "MIT", - "dependencies": { - "merge-options": "^3.0.4" - }, - "peerDependencies": { - "react-native": "^0.0.0-0 || >=0.65 <1.0" - } - }, - "node_modules/@react-native/assets-registry": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", - "integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==", - "license": "MIT", - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", - "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.3", - "@react-native/codegen": "0.81.5" - }, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/babel-preset": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", - "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-arrow-functions": "^7.24.7", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-computed-properties": "^7.24.7", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-function-name": "^7.25.1", - "@babel/plugin-transform-literals": "^7.25.2", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-numeric-separator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.25.2", - "@babel/plugin-transform-react-jsx-self": "^7.24.7", - "@babel/plugin-transform-react-jsx-source": "^7.24.7", - "@babel/plugin-transform-regenerator": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-shorthand-properties": "^7.24.7", - "@babel/plugin-transform-spread": "^7.24.7", - "@babel/plugin-transform-sticky-regex": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/template": "^7.25.0", - "@react-native/babel-plugin-codegen": "0.81.5", - "babel-plugin-syntax-hermes-parser": "0.29.1", - "babel-plugin-transform-flow-enums": "^0.0.2", - "react-refresh": "^0.14.0" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "node_modules/@react-native/codegen": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", - "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/parser": "^7.25.3", - "glob": "^7.1.1", - "hermes-parser": "0.29.1", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "yargs": "^17.6.2" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "node_modules/@react-native/codegen/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@react-native/codegen/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@react-native/codegen/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/@react-native/codegen/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.29.1" - } - }, - "node_modules/@react-native/codegen/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@react-native/community-cli-plugin": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz", - "integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==", - "license": "MIT", - "dependencies": { - "@react-native/dev-middleware": "0.81.5", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "metro": "^0.83.1", - "metro-config": "^0.83.1", - "metro-core": "^0.83.1", - "semver": "^7.1.3" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@react-native-community/cli": "*", - "@react-native/metro-config": "*" - }, - "peerDependenciesMeta": { - "@react-native-community/cli": { - "optional": true - }, - "@react-native/metro-config": { - "optional": true - } - } - }, - "node_modules/@react-native/debugger-frontend": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", - "integrity": "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/dev-middleware": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz", - "integrity": "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==", - "license": "MIT", - "dependencies": { - "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.81.5", - "chrome-launcher": "^0.15.2", - "chromium-edge-launcher": "^0.2.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "open": "^7.0.3", - "serve-static": "^1.16.2", - "ws": "^6.2.3" - }, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "license": "MIT", - "dependencies": { - "async-limiter": "~1.0.0" - } - }, - "node_modules/@react-native/gradle-plugin": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz", - "integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==", - "license": "MIT", - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/js-polyfills": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", - "integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==", - "license": "MIT", - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/normalize-colors": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", - "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", - "license": "MIT" - }, - "node_modules/@react-navigation/bottom-tabs": { - "version": "7.8.11", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.8.11.tgz", - "integrity": "sha512-lUc8cYpez3uVi7IlqKgIBpLEEkYiL4LkZnpstDsb0OSRxW8VjVYVrH29AqKU7n1svk++vffJvv3EeW+IgxkJtg==", - "license": "MIT", - "dependencies": { - "@react-navigation/elements": "^2.9.1", - "color": "^4.2.3", - "sf-symbols-typescript": "^2.1.0" - }, - "peerDependencies": { - "@react-navigation/native": "^7.1.24", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" - } - }, - "node_modules/@react-navigation/core": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.0.tgz", - "integrity": "sha512-E4Kr1PRrhKiVn1RdMdPIG1rCfrKh+HiVJ2smdLsh9D95Q2z0a9dGE9yHpRQ2pAUiiwOfgloLqegkPb8g+TcCBA==", - "license": "MIT", - "dependencies": { - "@react-navigation/routers": "^7.5.3", - "escape-string-regexp": "^4.0.0", - "fast-deep-equal": "^3.1.3", - "nanoid": "^3.3.11", - "query-string": "^7.1.3", - "react-is": "^19.1.0", - "use-latest-callback": "^0.2.4", - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "react": ">= 18.2.0" - } - }, - "node_modules/@react-navigation/core/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-navigation/core/node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT" - }, - "node_modules/@react-navigation/elements": { - "version": "2.9.12", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.12.tgz", - "integrity": "sha512-LSaQUj5SV9OXVRcxT8mqETDoM7BOKCveCvuLjdAr9NZnPDM5HW8uDnvW/sCa8oEFy+22+ojoXtHFKsfnesgBbw==", - "license": "MIT", - "dependencies": { - "color": "^4.2.3", - "use-latest-callback": "^0.2.4", - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.2.0", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0" - }, - "peerDependenciesMeta": { - "@react-native-masked-view/masked-view": { - "optional": true - } - } - }, - "node_modules/@react-navigation/native": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.0.tgz", - "integrity": "sha512-kEuqIS1MkzzLD45Fp17CrxAchoB4W6tMfc541merUgtAeNNsg06gRrvmuLv6LAYvpLifGdXuSjpluPIu/VmbQw==", - "license": "MIT", - "dependencies": { - "@react-navigation/core": "^7.17.0", - "escape-string-regexp": "^4.0.0", - "fast-deep-equal": "^3.1.3", - "nanoid": "^3.3.11", - "use-latest-callback": "^0.2.4" - }, - "peerDependencies": { - "react": ">= 18.2.0", - "react-native": "*" - } - }, - "node_modules/@react-navigation/native-stack": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.8.5.tgz", - "integrity": "sha512-IfAe80IQWlJec2Pri91FRi4EEBIc5+j191XZIJZKpexumCLfT+AKnfc0g3Sr4m0P6jrVVGtKb+XW+2jYj5mWRg==", - "license": "MIT", - "dependencies": { - "@react-navigation/elements": "^2.9.1", - "color": "^4.2.3", - "sf-symbols-typescript": "^2.1.0", - "warn-once": "^0.1.1" - }, - "peerDependencies": { - "@react-navigation/native": "^7.1.24", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" - } - }, - "node_modules/@react-navigation/native/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-navigation/routers": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", - "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11" - } - }, - "node_modules/@react-navigation/stack": { - "version": "7.8.7", - "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.8.7.tgz", - "integrity": "sha512-DXQrzDkMfpnju5d+3pnYEWSe0bZCcXfwAvhP9dw5v8CviVR0BhtYxVQVemkNIbwkLBAMOt6U4Py15mLS+Fkrag==", - "license": "MIT", - "dependencies": { - "@react-navigation/elements": "^2.9.12", - "color": "^4.2.3", - "use-latest-callback": "^0.2.4" - }, - "peerDependencies": { - "@react-navigation/native": "^7.2.0", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-gesture-handler": ">= 2.0.0", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/hammerjs": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", - "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@urql/core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", - "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", - "license": "MIT", - "dependencies": { - "@0no-co/graphql.web": "^1.0.13", - "wonka": "^6.3.2" - } - }, - "node_modules/@urql/exchange-retry": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", - "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", - "license": "MIT", - "dependencies": { - "@urql/core": "^5.1.2", - "wonka": "^6.3.2" - }, - "peerDependencies": { - "@urql/core": "^5.0.0" - } - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/anser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", - "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", - "license": "MIT" - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" - }, - "node_modules/assert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } - }, - "node_modules/async-limiter": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-jest/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/babel-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/babel-jest/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/babel-jest/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/babel-jest/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-jest/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", - "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", - "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-react-compiler": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", - "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.26.0" - } - }, - "node_modules/babel-plugin-react-native-web": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", - "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", - "license": "MIT" - }, - "node_modules/babel-plugin-syntax-hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz", - "integrity": "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==", - "license": "MIT", - "dependencies": { - "hermes-parser": "0.29.1" - } - }, - "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.29.1" - } - }, - "node_modules/babel-plugin-transform-flow-enums": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", - "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-flow": "^7.12.1" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-expo": { - "version": "54.0.8", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.8.tgz", - "integrity": "sha512-3ZJ4Q7uQpm8IR/C9xbKhE/IUjGpLm+OIjF8YCedLgqoe/wN1Ns2wLT7HwG6ZXXb6/rzN8IMCiKFQ2F93qlN6GA==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/preset-react": "^7.22.15", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-preset": "0.81.5", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.29.1", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - } - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/badgin": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", - "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", - "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/better-opn": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", - "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", - "license": "MIT", - "dependencies": { - "open": "^8.0.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/better-opn/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "license": "Unlicense", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "license": "ISC" - }, - "node_modules/bplist-creator": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", - "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", - "license": "MIT", - "dependencies": { - "stream-buffers": "2.2.x" - } - }, - "node_modules/bplist-parser": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", - "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", - "license": "MIT", - "dependencies": { - "big-integer": "1.6.x" - }, - "engines": { - "node": ">= 5.10.0" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001759", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", - "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/chrome-launcher": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", - "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0" - }, - "bin": { - "print-chrome-path": "bin/print-chrome-path.js" - }, - "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/chrome-launcher/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.81.5", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^6.2.3" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 20.19.4" } }, - "node_modules/chromium-edge-launcher": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", - "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", - "license": "Apache-2.0", + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", + "license": "MIT", "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0", - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" + "async-limiter": "~1.0.0" } }, - "node_modules/chromium-edge-launcher/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/@react-native/gradle-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz", + "integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==", "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 20.19.4" } }, - "node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "license": "MIT" + "node_modules/@react-native/js-polyfills": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", + "integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, + "node_modules/@react-native/normalize-colors": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", + "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", "license": "MIT" }, - "node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "node_modules/@react-native/virtualized-lists": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", + "integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==", "license": "MIT", "dependencies": { - "restore-cursor": "^2.0.0" + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" }, "engines": { - "node": ">=4" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">= 20.19.4" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@types/react": "^19.1.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.18.5", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.5.tgz", + "integrity": "sha512-BiXkpEprKwE++p64qjfUOhsK+e0VW8r5FsLbDOp8Mcr/vhoT9aqX3a4pHCVQ8ug5YQW+QrgF4/XIXI99CZxMnQ==", "license": "MIT", "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" + "@react-navigation/elements": "^2.9.27", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@react-navigation/native": "^7.3.5", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" } }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, + "node_modules/@react-navigation/core": { + "version": "7.21.3", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.21.3.tgz", + "integrity": "sha512-HUoGmT9IdpKpbAl9AZJmXFbid+jQWQWjzww9vV9uBSd+8fa1hTwM3UBsZBHrpMru0s1ikwknoAIwV827fqGlhA==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@react-navigation/routers": "^7.6.0", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "peerDependencies": { + "react": ">= 18.2.0" } }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, + "node_modules/@react-navigation/elements": { + "version": "2.9.27", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.27.tgz", + "integrity": "sha512-6bka/gWvP3+5Dw9Gf2ac83y/F0XEl2ktezc6Yp3gJBs22Rm9dluGKphe1B2Hj33XoCMRofxL8w3z3kmtlJiC0Q==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" }, - "engines": { - "node": ">=20" + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.3.5", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } } }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, + "node_modules/@react-navigation/native": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.5.tgz", + "integrity": "sha512-lzcRpMoLCIypXXKm8KhpyZ81XpQBO/i8Oy1due4kUR97kG8kBlJxKUeP8yNnT5Fcl85rNdLXWbpwEr+9HPu4Ig==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" + "@react-navigation/core": "^7.21.3", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "standard-navigation": "^0.0.7", + "use-latest-callback": "^0.2.4" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", + "node_modules/@react-navigation/native-stack": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.17.7.tgz", + "integrity": "sha512-kNM39MJu8qRiaSJdszJqQfvcBhbGHi4dXTvKiEmZe97EDUcDb3SyajG6T+08IZ6l5CFdlmCcM03VY3u/nCvWcw==", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "@react-navigation/elements": "^2.9.27", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@react-navigation/native": "^7.3.5", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "node_modules/@react-navigation/routers": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.6.0.tgz", + "integrity": "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==", "license": "MIT", - "engines": { - "node": ">=0.8" + "dependencies": { + "nanoid": "^3.3.11" } }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, + "node_modules/@react-navigation/stack": { + "version": "7.10.7", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.10.7.tgz", + "integrity": "sha512-rkJ9CEJnOHRSBVYNq7M8+0a34munKcTho/HSJiniRM3ZDjWR0aQKDXZcYpKi+IQ8jKv2XyYOvZLGHrBNJc5ipA==", "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" + "dependencies": { + "@react-navigation/elements": "^2.9.27", + "color": "^4.2.3", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "@react-navigation/native": "^7.3.5", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-gesture-handler": ">= 2.0.0", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" } }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT" }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "@babel/types": "^7.0.0" } }, - "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "license": "MIT", "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/color/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "@babel/types": "^7.28.2" } }, - "node_modules/color/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "@types/node": "*" } }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "license": "MIT", "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" + "@types/istanbul-lib-coverage": "*" } }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" + "@types/istanbul-lib-report": "*" } }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "undici-types": "~8.3.0" } }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@types/yargs-parser": "*" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, + "node_modules/@urql/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", + "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" + "@0no-co/graphql.web": "^1.0.13", + "wonka": "^6.3.2" } }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/@urql/exchange-retry": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", + "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "@urql/core": "^5.1.2", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^5.0.0" } }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" + "engines": { + "node": ">=10.0.0" } }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" + "event-target-shim": "^5.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6.5" } }, - "node_modules/create-jest/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 0.6" } }, - "node_modules/create-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=0.4.0" } }, - "node_modules/create-jest/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "debug": "4" }, "engines": { - "node": ">=7.0.0" + "node": ">= 6.0.0" } }, - "node_modules/create-jest/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", "license": "MIT" }, - "node_modules/create-jest/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/create-jest/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { "node": ">=8" } }, - "node_modules/cross-fetch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", - "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { - "node-fetch": "^2.7.0" + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { "node": ">= 8" } }, - "node_modules/crypto-random-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/css-in-js-utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", - "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", "dependencies": { - "hyphenate-style-name": "^1.0.3" + "sprintf-js": "~1.0.2" } }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" }, - "node_modules/css-tree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", - "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", "license": "MIT", "dependencies": { - "mdn-data": "2.0.14", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=8.0.0" + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" } }, - "node_modules/css-tree/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "license": "MIT" }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "possible-typed-array-names": "^1.0.0" }, "engines": { - "node": ">=6.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", - "engines": { - "node": ">=0.10" + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, - "node_modules/dedent": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", - "dev": true, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "license": "MIT", + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", "dependencies": { - "clone": "^1.0.2" + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/delayed-stream": { + "node_modules/babel-plugin-react-compiler": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "license": "MIT", - "engines": { - "node": ">=0.4.0" + "dependencies": { + "@babel/types": "^7.26.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz", + "integrity": "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "hermes-parser": "0.29.1" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, + "node_modules/babel-preset-expo": { + "version": "54.0.11", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.11.tgz", + "integrity": "sha512-dEpeFDtYEFzmWtWVwvt7sUCZH0fxXPfbJlgXd7XNZSQDa/Ki/hTOj9exMTzqR2oyPHDNcE9VxYCJ4oS6xw4Pjg==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.81.5", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + } } }, - "node_modules/diff-sequences": { + "node_modules/babel-preset-jest": { "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/fb55" + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } ], - "license": "BSD-2-Clause" + "license": "MIT" }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "node": ">=6.0.0" } }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "license": "MIT", "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "open": "^8.0.4" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "license": "BSD-2-Clause", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" + "node": ">=12.0.0" } }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "license": "BSD-2-Clause", + "node_modules/better-opn/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", "dependencies": { - "dotenv": "^16.4.5" + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" }, "engines": { "node": ">=12" }, "funding": { - "url": "https://dotenvx.com" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", "engines": { - "node": ">= 0.4" + "node": ">=0.6" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.266", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", - "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" + "dependencies": { + "stream-buffers": "2.2.x" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, "engines": { - "node": ">= 0.8" + "node": ">= 5.10.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/env-editor": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", - "integrity": "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, "engines": { "node": ">=8" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "is-arrayish": "^0.2.1" + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, - "node_modules/error-ex/node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", - "dependencies": { - "stackframe": "^1.3.4" + "engines": { + "node": ">= 0.8" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, "engines": { "node": ">= 0.4" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=0.8.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=10" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, "engines": { - "node": ">= 0.6" + "node": ">=12.13.0" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "node_modules/chromium-edge-launcher": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", + "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true, "license": "MIT" }, - "node_modules/exec-async": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", - "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", - "license": "MIT" - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/execa/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/execa/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { - "node": ">=6" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.8.0" + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" } }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "color-convert": "^2.0.1", + "color-string": "^1.9.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12.5.0" } }, - "node_modules/expo": { - "version": "54.0.27", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.27.tgz", - "integrity": "sha512-50BcJs8eqGwRiMUoWwphkRGYtKFS2bBnemxLzy0lrGVA1E6F4Q7L5h3WT6w1ehEZybtOVkfJu4Z6GWo2IJcpEA==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.18", - "@expo/config": "~12.0.11", - "@expo/config-plugins": "~54.0.3", - "@expo/devtools": "0.1.8", - "@expo/fingerprint": "0.15.4", - "@expo/metro": "~54.1.0", - "@expo/metro-config": "54.0.10", - "@expo/vector-icons": "^15.0.3", - "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~54.0.8", - "expo-asset": "~12.0.11", - "expo-constants": "~18.0.11", - "expo-file-system": "~19.0.20", - "expo-font": "~14.0.10", - "expo-keep-awake": "~15.0.8", - "expo-modules-autolinking": "3.0.23", - "expo-modules-core": "3.0.28", - "pretty-format": "^29.7.0", - "react-refresh": "^0.14.2", - "whatwg-url-without-unicode": "8.0.0-3" - }, - "bin": { - "expo": "bin/cli", - "expo-modules-autolinking": "bin/autolinking", - "fingerprint": "bin/fingerprint" - }, - "peerDependencies": { - "@expo/dom-webview": "*", - "@expo/metro-runtime": "*", - "react": "*", - "react-native": "*", - "react-native-webview": "*" + "color-name": "~1.1.4" }, - "peerDependenciesMeta": { - "@expo/dom-webview": { - "optional": true - }, - "@expo/metro-runtime": { - "optional": true - }, - "react-native-webview": { - "optional": true - } + "engines": { + "node": ">=7.0.0" } }, - "node_modules/expo-application": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", - "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, - "node_modules/expo-constants": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", - "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" } }, - "node_modules/expo-font": { - "version": "14.0.10", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", - "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { - "fontfaceobserver": "^2.1.0" + "delayed-stream": "~1.0.0" }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" + "engines": { + "node": ">= 0.8" } }, - "node_modules/expo-haptics": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", - "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", - "peerDependencies": { - "expo": "*" + "engines": { + "node": ">= 10" } }, - "node_modules/expo-linear-gradient": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", - "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==", + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" } }, - "node_modules/expo-modules-autolinking": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.23.tgz", - "integrity": "sha512-YZnaE0G+52xftjH5nsIRaWsoVBY38SQCECclpdgLisdbRY/6Mzo7ndokjauOv3mpFmzMZACHyJNu1YSAffQwTg==", + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.1.0", - "commander": "^7.2.0", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0" + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" }, - "bin": { - "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/expo-modules-autolinking/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 0.6" } }, - "node_modules/expo-modules-autolinking/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">= 0.10.0" } }, - "node_modules/expo-modules-autolinking/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "ms": "2.0.0" } }, - "node_modules/expo-modules-autolinking/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/expo-modules-autolinking/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" } }, - "node_modules/expo-modules-autolinking/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/expo-modules-core": { - "version": "3.0.28", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.28.tgz", - "integrity": "sha512-8EDpksNxnN4HXWE+yhYUYAZAWTEDRzK2VpZjPSp+UBF2LtWZicXKLOCODCvsjCkTCVVA2JKKcWtGxWiteV3ueA==", + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", "license": "MIT", "dependencies": { - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" + "node-fetch": "^2.7.0" } }, - "node_modules/expo-notifications": { - "version": "0.32.17", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz", - "integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.8", - "@ide/backoff": "^1.0.0", - "abort-controller": "^3.0.0", - "assert": "^2.0.0", - "badgin": "^1.1.5", - "expo-application": "~7.0.8", - "expo-constants": "~18.0.13" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo-secure-store": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", - "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", - "license": "MIT", - "peerDependencies": { - "expo": "*" + "engines": { + "node": ">= 8" } }, - "node_modules/expo-server": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", - "integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==", + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", "license": "MIT", - "engines": { - "node": ">=20.16.0" + "dependencies": { + "hyphenate-style-name": "^1.0.3" } }, - "node_modules/expo-status-bar": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", - "integrity": "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==", - "license": "MIT", + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", "dependencies": { - "react-native-is-edge-to-edge": "^1.2.1" + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" }, - "peerDependencies": { - "react": "*", - "react-native": "*" + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/expo/node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "mdn-data": "2.0.14", + "source-map": "^0.6.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/expo/node_modules/@expo/cli": { - "version": "54.0.18", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.18.tgz", - "integrity": "sha512-hN4kolUXLah9T8DQJ8ue1ZTvRNbeNJOEOhLBak6EU7h90FKfjLA32nz99jRnHmis+aF+9qsrQG9yQx9eCSVDcg==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "@0no-co/graphql.web": "^1.0.8", - "@expo/code-signing-certificates": "^0.0.5", - "@expo/config": "~12.0.11", - "@expo/config-plugins": "~54.0.3", - "@expo/devcert": "^1.2.1", - "@expo/env": "~2.0.8", - "@expo/image-utils": "^0.8.8", - "@expo/json-file": "^10.0.8", - "@expo/metro": "~54.1.0", - "@expo/metro-config": "~54.0.10", - "@expo/osascript": "^2.3.8", - "@expo/package-manager": "^1.9.9", - "@expo/plist": "^0.4.8", - "@expo/prebuild-config": "^54.0.7", - "@expo/schema-utils": "^0.1.8", - "@expo/spawn-async": "^1.7.2", - "@expo/ws-tunnel": "^1.0.1", - "@expo/xcpretty": "^4.3.0", - "@react-native/dev-middleware": "0.81.5", - "@urql/core": "^5.0.6", - "@urql/exchange-retry": "^1.3.0", - "accepts": "^1.3.8", - "arg": "^5.0.2", - "better-opn": "~3.0.2", - "bplist-creator": "0.1.0", - "bplist-parser": "^0.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.3.0", - "compression": "^1.7.4", - "connect": "^3.7.0", - "debug": "^4.3.4", - "env-editor": "^0.4.1", - "expo-server": "^1.0.5", - "freeport-async": "^2.0.0", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "lan-network": "^0.1.6", - "minimatch": "^9.0.0", - "node-forge": "^1.3.1", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "picomatch": "^3.0.1", - "pretty-bytes": "^5.6.0", - "pretty-format": "^29.7.0", - "progress": "^2.0.3", - "prompts": "^2.3.2", - "qrcode-terminal": "0.11.0", - "require-from-string": "^2.0.2", - "requireg": "^0.2.2", - "resolve": "^1.22.2", - "resolve-from": "^5.0.0", - "resolve.exports": "^2.0.3", - "semver": "^7.6.0", - "send": "^0.19.0", - "slugify": "^1.3.4", - "source-map-support": "~0.5.21", - "stacktrace-parser": "^0.1.10", - "structured-headers": "^0.4.1", - "tar": "^7.5.2", - "terminal-link": "^2.1.1", - "undici": "^6.18.2", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1" - }, - "bin": { - "expo-internal": "build/bin/cli" + "ms": "^2.1.3" }, - "peerDependencies": { - "expo": "*", - "expo-router": "*", - "react-native": "*" + "engines": { + "node": ">=6.0" }, "peerDependenciesMeta": { - "expo-router": { - "optional": true - }, - "react-native": { + "supports-color": { "optional": true } } }, - "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/prebuild-config": { - "version": "54.0.7", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.7.tgz", - "integrity": "sha512-cKqBsiwcFFzpDWgtvemrCqJULJRLDLKo2QMF74NusoGNpfPI3vQVry1iwnYLeGht02AeD3dvfhpqBczD3wchxA==", + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "license": "MIT", - "dependencies": { - "@expo/config": "~12.0.11", - "@expo/config-plugins": "~54.0.3", - "@expo/config-types": "^54.0.9", - "@expo/image-utils": "^0.8.8", - "@expo/json-file": "^10.0.8", - "@react-native/normalize-colors": "0.81.5", - "debug": "^4.3.1", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" - }, - "peerDependencies": { - "expo": "*" + "engines": { + "node": ">=0.10" } }, - "node_modules/expo/node_modules/@expo/metro-config": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.10.tgz", - "integrity": "sha512-AkSTwaWbMMDOiV4RRy4Mv6MZEOW5a7BZlgtrWxvzs6qYKRxKLKH/qqAuKe0bwGepF1+ws9oIX5nQjtnXRwezvQ==", + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~12.0.11", - "@expo/env": "~2.0.7", - "@expo/json-file": "~10.0.7", - "@expo/metro": "~54.1.0", - "@expo/spawn-async": "^1.7.2", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.29.1", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "minimatch": "^9.0.0", - "postcss": "~8.4.32", - "resolve-from": "^5.0.0" - }, "peerDependencies": { - "expo": "*" + "babel-plugin-macros": "^3.1.0" }, "peerDependenciesMeta": { - "expo": { + "babel-plugin-macros": { "optional": true } } }, - "node_modules/expo/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/expo/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/expo/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/expo/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { - "node": ">=7.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/expo/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/expo/node_modules/expo-asset": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.11.tgz", - "integrity": "sha512-pnK/gQ5iritDPBeK54BV35ZpG7yeW5DtgGvJHruIXkyDT9BCoQq3i0AAxfcWG/e4eiRmTzAt5kNVYFJi48uo+A==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.8.8", - "expo-constants": "~18.0.11" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" + "engines": { + "node": ">=0.4.0" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "19.0.20", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.20.tgz", - "integrity": "sha512-Jr/nNvJmUlptS3cHLKVBNyTyGMHNyxYBKRph1KRe0Nb3RzZza1gZLZXMG5Ky//sO2azTn+OaT0dv/lAyL0vJNA==", + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" + "engines": { + "node": ">= 0.8" } }, - "node_modules/expo/node_modules/expo-keep-awake": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", - "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*" + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/expo/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/expo/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } }, - "node_modules/expo/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "license": "MIT", "dependencies": { - "hermes-estree": "0.29.1" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/expo/node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", - "license": "MIT", + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, "engines": { - "node": ">=10" + "node": ">= 4" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/expo/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", "dependencies": { - "has-flag": "^4.0.0" + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/expo/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "node": ">=12" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" - }, - "node_modules/fast-loops": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.4.tgz", - "integrity": "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg==", - "license": "MIT" + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "license": "Apache-2.0", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", "dependencies": { - "bser": "2.1.1" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/fbjs": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", - "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.384", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", + "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==", + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, "license": "MIT", - "dependencies": { - "cross-fetch": "^3.1.5", - "fbjs-css-vars": "^1.0.0", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^1.0.35" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, - "node_modules/fbjs-css-vars": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", - "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/fbjs/node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "license": "MIT", - "dependencies": { - "asap": "~2.0.3" + "engines": { + "node": ">= 0.8" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-editor": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", + "integrity": "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==", + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/filter-obj": { + "node_modules/environment": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", - "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "is-arrayish": "^0.2.1" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "stackframe": "^1.3.4" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/flow-enums-runtime": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", - "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", - "license": "MIT" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "node": ">= 0.4" } }, - "node_modules/fontfaceobserver": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", - "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", - "license": "BSD-2-Clause" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" + "es-errors": "^1.3.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/freeport-async": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", - "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=6" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">= 0.6" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=6" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "license": "MIT", + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, "engines": { - "node": ">=8.0.0" + "node": ">= 0.8.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": ">= 0.4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, + "node_modules/expo": { + "version": "54.0.35", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.35.tgz", + "integrity": "sha512-E+tXpQwjGm5fK/uwa55p0Xx/kuo5dXDKfVJ95IargTNa5KiFt26lSTXXa9KnHbI4EDLwFD38/xTKZvzPTlGTdg==", "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "54.0.25", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devtools": "0.1.8", + "@expo/fingerprint": "0.15.5", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "54.0.16", + "@expo/vector-icons": "^15.0.3", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~54.0.11", + "expo-asset": "~12.0.13", + "expo-constants": "~18.0.13", + "expo-file-system": "~19.0.23", + "expo-font": "~14.0.12", + "expo-keep-awake": "~15.0.8", + "expo-modules-autolinking": "3.0.26", + "expo-modules-core": "3.0.30", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-without-unicode": "8.0.0-3" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-native": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-native-webview": { + "optional": true + } } }, - "node_modules/getenv": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", - "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "node_modules/expo-application": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", "license": "MIT", - "engines": { - "node": ">=6" + "peerDependencies": { + "expo": "*" } }, - "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", - "license": "BlueOak-1.0.0", + "node_modules/expo-asset": { + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", + "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", + "license": "MIT", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" - }, - "engines": { - "node": "20 || >=22" + "@expo/image-utils": "^0.8.8", + "expo-constants": "~18.0.13" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "license": "BlueOak-1.0.0", + "node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "expo": "*", + "react-native": "*" } }, - "node_modules/global-dirs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", + "node_modules/expo-file-system": { + "version": "19.0.23", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", + "integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "14.0.12", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz", + "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==", "license": "MIT", "dependencies": { - "ini": "^1.3.4" + "fontfaceobserver": "^2.1.0" }, - "engines": { - "node": ">=4" + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/expo-haptics": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", + "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "expo": "*" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" + "node_modules/expo-keep-awake": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", + "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "node_modules/expo-linear-gradient": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", + "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==", "license": "MIT", - "engines": { - "node": ">=4" + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/expo-modules-autolinking": { + "version": "3.0.26", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz", + "integrity": "sha512-WOaud6UKg16ciCOj8raKcMOoKFMHLXKI29U29yhgu1lf+Y7VxJyCktUcYo6AM+ccZ7zLD1uWZdMtgnpf+95OXA==", "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/expo-modules-core": { + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz", + "integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "invariant": "^2.2.4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "*", + "react-native": "*" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/expo-notifications": { + "version": "0.32.17", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz", + "integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" + "@expo/image-utils": "^0.8.8", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13" }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-server": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.7.tgz", + "integrity": "sha512-mcmyML3oXcqFUXUxtdtCL1O00ztNI2v76d+MdniXRUgHNxIcHZ05zo+DqBaOOT6LQnPk4vA4YHqQl7iGUfRb3g==", + "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20.16.0" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/expo-status-bar": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", + "integrity": "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "react-native-is-edge-to-edge": "^1.2.1" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "react": "*", + "react-native": "*" } }, - "node_modules/hermes-estree": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", - "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", - "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", - "license": "MIT", + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-loops": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.4.tgz", + "integrity": "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg==", + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", "dependencies": { - "hermes-estree": "0.32.0" + "bser": "2.1.1" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", "dependencies": { - "react-is": "^16.7.0" + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" } }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", "license": "MIT" }, - "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "license": "ISC", + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" + "asap": "~2.0.3" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 14" + "node": ">= 0.8" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": ">=10.17.0" + "node": ">=8" } }, - "node_modules/hyphenate-style-name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", - "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", - "license": "BSD-3-Clause" + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "license": "MIT", "dependencies": { - "queue": "6.0.2" - }, - "bin": { - "image-size": "bin/image-size.js" + "is-callable": "^1.2.7" }, "engines": { - "node": ">=16.x" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 6" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/freeport-async": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", + "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", "license": "MIT", "engines": { - "node": ">=0.8.19" + "node": ">=8" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, - "node_modules/inline-style-prefixer": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz", - "integrity": "sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "css-in-js-utils": "^3.1.0", - "fast-loops": "^1.1.3" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -7810,68 +6256,98 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "license": "MIT", - "bin": { - "is-docker": "cli.js" + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "license": "MIT", + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">= 0.4" + "node": "*" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/is-nan": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, + "engines": { + "node": "*" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7879,62 +6355,52 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -7943,788 +6409,721 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { - "is-docker": "^2.0.0" + "function-bind": "^1.1.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "node_modules/hermes-estree": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", + "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", + "license": "MIT" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" + "node_modules/hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", + "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.29.1" } }, - "node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" + "react-is": "^16.7.0" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "lru-cache": "^10.0.1" }, "engines": { - "node": ">=10" + "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { - "node": ">=10" + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.8" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, + "license": "Apache-2.0", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">=10.17.0" } }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 4" } }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=16.x" } }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" }, "engines": { "node": ">=8" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-circus/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=0.8.19" } }, - "node_modules/jest-circus/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "once": "^1.3.0", + "wrappy": "1" } }, - "node_modules/jest-circus/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, - "node_modules/jest-circus/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" }, - "node_modules/jest-circus/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/inline-style-prefixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz", + "integrity": "sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "css-in-js-utils": "^3.1.0", + "fast-loops": "^1.1.3" } }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "loose-envify": "^1.0.0" } }, - "node_modules/jest-cli/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-cli/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-cli/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "hasown": "^2.0.3" }, "engines": { - "node": ">=7.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-cli/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-cli/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-cli/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "get-east-asian-width": "^1.3.1" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } + "node": ">=6" } }, - "node_modules/jest-config/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-config/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-config/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-config/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=0.12.0" } }, - "node_modules/jest-config/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, "engines": { - "node": ">=7.0.0" + "node": ">=8" } }, - "node_modules/jest-config/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-config/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": "*" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-config/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", "engines": { "node": ">=8" - } - }, - "node_modules/jest-config/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" }, - "engines": { - "node": "*" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-config/node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-config/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "is-docker": "^2.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", "dependencies": { - "color-convert": "^2.0.1" + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "ansi-styles": "^4.1.0", + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", "supports-color": "^7.1.0" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-diff/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "color-name": "~1.1.4" + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" }, "engines": { - "node": ">=7.0.0" + "node": ">=10" } }, - "node_modules/jest-diff/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-diff/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/jest-diff/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/jest-docblock": { + "node_modules/jest-changed-files": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-each": { + "node_modules/jest-circus": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", "dev": true, "license": "MIT", "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", + "@types/node": "*", "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-each/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/jest-each/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=10" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/jest-each/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">=7.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-each/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-each/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-each/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-environment-node": { @@ -8808,82 +7207,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-matcher-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-matcher-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-message-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", @@ -8904,90 +7227,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-message-util/node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-message-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/jest-message-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-message-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-mock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", @@ -9061,83 +7300,7 @@ "jest-snapshot": "^29.7.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-resolve/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-resolve/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-resolve/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-resolve/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-runner": { @@ -9173,79 +7336,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runner/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-runner/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-runner/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-runner/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-runner/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runner/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/jest-runner/node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -9257,19 +7347,6 @@ "source-map": "^0.6.0" } }, - "node_modules/jest-runner/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-runtime": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", @@ -9304,128 +7381,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runtime/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/jest-runtime/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-runtime/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-runtime/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-runtime/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-runtime/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runtime/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/jest-runtime/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", @@ -9457,81 +7412,18 @@ "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } - }, - "node_modules/jest-snapshot/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-snapshot/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-snapshot/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-snapshot/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-snapshot/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=8" + "node": ">=10" } }, "node_modules/jest-util": { @@ -9551,89 +7443,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/jest-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/jest-validate": { @@ -9653,74 +7472,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-validate/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/jest-validate/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/jest-watcher": { @@ -9743,82 +7504,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-watcher/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-watcher/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-watcher/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-watcher/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-watcher/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-watcher/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-worker": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", @@ -9834,15 +7519,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -9871,9 +7547,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -9930,9 +7606,9 @@ } }, "node_modules/lan-network": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", - "integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", "license": "MIT", "bin": { "lan-network": "dist/lan-network-cli.js" @@ -9973,9 +7649,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -9988,23 +7664,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -10022,9 +7698,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -10042,9 +7718,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -10062,9 +7738,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -10082,9 +7758,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -10102,12 +7778,15 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10122,12 +7801,15 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10142,12 +7824,15 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10162,12 +7847,15 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10182,9 +7870,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -10202,9 +7890,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -10228,9 +7916,9 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "license": "MIT", "dependencies": { @@ -10252,23 +7940,10 @@ "yaml": "^2.9.0" } }, - "node_modules/lint-staged/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", + "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", "dev": true, "license": "MIT", "dependencies": { @@ -10296,33 +7971,16 @@ } }, "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, "engines": { - "node": ">=20" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/listr2/node_modules/strip-ansi": { @@ -10395,6 +8053,77 @@ "node": ">=4" } }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -10457,22 +8186,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log-update/node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update/node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -10480,68 +8193,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/log-update/node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -10624,9 +8275,9 @@ } }, "node_modules/lottie-react-native": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-7.3.6.tgz", - "integrity": "sha512-TevFHRvFURh6GlaqLKrSNXuKAxvBvFCiXfS7FXQI1K/ikOStgAwWLFPGjW0i1qB2/VzPACKmRs+535VjHUZZZQ==", + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-7.3.8.tgz", + "integrity": "sha512-GAOl99TKi0c6xCcB1AJ+o70mgOPIAI/7K2G+Gs+o4po/qBhgvWijPNCKH8h6yNmlwFTg+RN3DmzRvHNFGxZMKQ==", "license": "Apache-2.0", "peerDependencies": { "@lottiefiles/dotlottie-react": "^0.13.5", @@ -10668,6 +8319,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -10723,9 +8387,9 @@ "license": "MIT" }, "node_modules/metro": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.2.tgz", - "integrity": "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz", + "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.24.7", @@ -10749,18 +8413,18 @@ "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.83.2", - "metro-cache": "0.83.2", - "metro-cache-key": "0.83.2", - "metro-config": "0.83.2", - "metro-core": "0.83.2", - "metro-file-map": "0.83.2", - "metro-resolver": "0.83.2", - "metro-runtime": "0.83.2", - "metro-source-map": "0.83.2", - "metro-symbolicate": "0.83.2", - "metro-transform-plugins": "0.83.2", - "metro-transform-worker": "0.83.2", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", @@ -10777,9 +8441,9 @@ } }, "node_modules/metro-babel-transformer": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.2.tgz", - "integrity": "sha512-rirY1QMFlA1uxH3ZiNauBninwTioOgwChnRdDcbB4tgRZ+bGX9DiXoh9QdpppiaVKXdJsII932OwWXGGV4+Nlw==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz", + "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", @@ -10791,25 +8455,40 @@ "node": ">=20.19.4" } }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/metro-cache": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.2.tgz", - "integrity": "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", + "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", "license": "MIT", "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", - "metro-core": "0.83.2" + "metro-core": "0.83.3" }, "engines": { "node": ">=20.19.4" } }, "node_modules/metro-cache-key": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.2.tgz", - "integrity": "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz", + "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" @@ -10818,19 +8497,41 @@ "node": ">=20.19.4" } }, + "node_modules/metro-cache/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/metro-cache/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/metro-config": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.2.tgz", - "integrity": "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz", + "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", "license": "MIT", "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", - "metro": "0.83.2", - "metro-cache": "0.83.2", - "metro-core": "0.83.2", - "metro-runtime": "0.83.2", + "metro": "0.83.3", + "metro-cache": "0.83.3", + "metro-core": "0.83.3", + "metro-runtime": "0.83.3", "yaml": "^2.6.1" }, "engines": { @@ -10838,23 +8539,23 @@ } }, "node_modules/metro-core": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.2.tgz", - "integrity": "sha512-8DRb0O82Br0IW77cNgKMLYWUkx48lWxUkvNUxVISyMkcNwE/9ywf1MYQUE88HaKwSrqne6kFgCSA/UWZoUT0Iw==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz", + "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", - "metro-resolver": "0.83.2" + "metro-resolver": "0.83.3" }, "engines": { "node": ">=20.19.4" } }, "node_modules/metro-file-map": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.2.tgz", - "integrity": "sha512-cMSWnEqZrp/dzZIEd7DEDdk72PXz6w5NOKriJoDN9p1TDQ5nAYrY2lHi8d6mwbcGLoSlWmpPyny9HZYFfPWcGQ==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz", + "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -10872,9 +8573,9 @@ } }, "node_modules/metro-minify-terser": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.2.tgz", - "integrity": "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz", + "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", @@ -10885,9 +8586,9 @@ } }, "node_modules/metro-resolver": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.2.tgz", - "integrity": "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz", + "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" @@ -10897,9 +8598,9 @@ } }, "node_modules/metro-runtime": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.2.tgz", - "integrity": "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz", + "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.0", @@ -10910,9 +8611,9 @@ } }, "node_modules/metro-source-map": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.2.tgz", - "integrity": "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz", + "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.25.3", @@ -10920,9 +8621,9 @@ "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-symbolicate": "0.83.2", + "metro-symbolicate": "0.83.3", "nullthrows": "^1.1.1", - "ob1": "0.83.2", + "ob1": "0.83.3", "source-map": "^0.5.6", "vlq": "^1.0.0" }, @@ -10930,15 +8631,24 @@ "node": ">=20.19.4" } }, + "node_modules/metro-source-map/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/metro-symbolicate": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.2.tgz", - "integrity": "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz", + "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-source-map": "0.83.2", + "metro-source-map": "0.83.3", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" @@ -10950,129 +8660,105 @@ "node": ">=20.19.4" } }, - "node_modules/metro-transform-plugins": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.2.tgz", - "integrity": "sha512-5WlW25WKPkiJk2yA9d8bMuZrgW7vfA4f4MBb9ZeHbTB3eIAoNN8vS8NENgG/X/90vpTB06X66OBvxhT3nHwP6A==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.3", - "flow-enums-runtime": "^0.0.6", - "nullthrows": "^1.1.1" - }, + "node_modules/metro-symbolicate/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", "engines": { - "node": ">=20.19.4" + "node": ">=0.10.0" } }, - "node_modules/metro-transform-worker": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.2.tgz", - "integrity": "sha512-G5DsIg+cMZ2KNfrdLnWMvtppb3+Rp1GMyj7Bvd9GgYc/8gRmvq1XVEF9XuO87Shhb03kFhGqMTgZerz3hZ1v4Q==", + "node_modules/metro-transform-plugins": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz", + "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", - "@babel/parser": "^7.25.3", - "@babel/types": "^7.25.2", - "flow-enums-runtime": "^0.0.6", - "metro": "0.83.2", - "metro-babel-transformer": "0.83.2", - "metro-cache": "0.83.2", - "metro-cache-key": "0.83.2", - "metro-minify-terser": "0.83.2", - "metro-source-map": "0.83.2", - "metro-transform-plugins": "0.83.2", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro/node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/metro/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/metro/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=20.19.4" } }, - "node_modules/metro/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/metro-transform-worker": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz", + "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-source-map": "0.83.3", + "metro-transform-plugins": "0.83.3", + "nullthrows": "^1.1.1" }, "engines": { - "node": ">=7.0.0" + "node": ">=20.19.4" } }, - "node_modules/metro/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "license": "MIT" }, - "node_modules/metro/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/metro/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/metro/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/metro/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/micromatch": { @@ -11088,6 +8774,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -11101,9 +8799,9 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -11121,13 +8819,23 @@ "node": ">= 0.6" } }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/mimic-function": { @@ -11144,12 +8852,12 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -11168,10 +8876,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -11218,9 +8926,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -11278,9 +8986,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -11293,10 +9001,13 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -11322,6 +9033,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -11354,9 +9077,9 @@ "license": "MIT" }, "node_modules/ob1": { - "version": "0.83.2", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.2.tgz", - "integrity": "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg==", + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz", + "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" @@ -11462,15 +9185,19 @@ } }, "node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^1.0.0" + "mimic-fn": "^2.1.0" }, "engines": { - "node": ">=4" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/open": { @@ -11515,6 +9242,111 @@ "node": ">=6" } }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ora/node_modules/strip-ansi": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", @@ -11527,6 +9359,18 @@ "node": ">=6" } }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11652,25 +9496,25 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11692,12 +9536,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -11726,12 +9570,12 @@ } }, "node_modules/plist": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", - "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", "license": "MIT", "dependencies": { - "@xmldom/xmldom": "^0.8.8", + "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" }, @@ -11739,6 +9583,15 @@ "node": ">=10.4.0" } }, + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/pngjs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", @@ -11835,6 +9688,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", @@ -11919,12 +9778,13 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -11984,6 +9844,15 @@ "rc": "cli.js" } }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -12003,6 +9872,27 @@ "ws": "^7" } }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", @@ -12028,9 +9918,9 @@ } }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, "node_modules/react-native": { @@ -12091,9 +9981,9 @@ } }, "node_modules/react-native-chart-kit": { - "version": "6.12.2", - "resolved": "https://registry.npmjs.org/react-native-chart-kit/-/react-native-chart-kit-6.12.2.tgz", - "integrity": "sha512-99gwywj5ZkGRgtWUF6Th78u8bfLcIseNXN0cOYH4sRh3adHHwmJVepByroYqlxWxQa2i8gBDOBCZaS8kSltXkQ==", + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/react-native-chart-kit/-/react-native-chart-kit-6.12.3.tgz", + "integrity": "sha512-Wc7akObFaa7wAF42JGSvmd25OUpngLy412aIgJIjkmOMy3jBoPEX/yu8OPiSV3F2bSliWqlAcaqOrxuKIJy5Iw==", "license": "MIT", "dependencies": { "paths-js": "^0.4.10", @@ -12133,9 +10023,9 @@ } }, "node_modules/react-native-is-edge-to-edge": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", - "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", "license": "MIT", "peerDependencies": { "react": "*", @@ -12143,9 +10033,9 @@ } }, "node_modules/react-native-safe-area-context": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", - "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", + "integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==", "license": "MIT", "peerDependencies": { "react": "*", @@ -12214,39 +10104,6 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, - "node_modules/react-native/node_modules/@react-native/virtualized-lists": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", - "integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==", - "license": "MIT", - "dependencies": { - "invariant": "^2.2.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@types/react": "^19.1.0", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-native/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -12256,43 +10113,22 @@ "node": ">=18" } }, - "node_modules/react-native/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/react-native/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/react-native/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": "*" + "node": ">=10" } }, "node_modules/react-native/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", "license": "MIT", "dependencies": { "async-limiter": "~1.0.0" @@ -12355,9 +10191,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -12407,11 +10243,12 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -12448,22 +10285,10 @@ "node": ">=8" } }, - "node_modules/resolve-global": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", - "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", - "license": "MIT", - "dependencies": { - "global-dirs": "^0.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-workspace-root": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.0.tgz", - "integrity": "sha512-IsaBUZETJD5WsI11Wt8PKHwaIe45or6pwNc8yflvLJ4DWtImK9kuLoH5kUva/2Mmx/RdIyr4aONNSa2v9LTJsw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", "license": "MIT" }, "node_modules/resolve.exports": { @@ -12476,16 +10301,49 @@ } }, "node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, "license": "MIT", "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/rfdc": { @@ -12511,49 +10369,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -12592,10 +10407,13 @@ } }, "node_modules/sax": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", - "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", - "license": "BlueOak-1.0.0" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/scheduler": { "version": "0.26.0", @@ -12604,36 +10422,33 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -12654,6 +10469,15 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/send/node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12667,9 +10491,9 @@ } }, "node_modules/send/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -12685,15 +10509,15 @@ } }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" @@ -12768,9 +10592,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -12780,14 +10604,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -12868,6 +10692,18 @@ "plist": "^3.0.5" } }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -12877,6 +10713,12 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -12922,35 +10764,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "license": "MIT", "engines": { "node": ">=8.0.0" } }, "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12966,22 +10792,13 @@ } }, "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" } }, "node_modules/split-on-first": { @@ -13038,6 +10855,21 @@ "node": ">=6" } }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/standard-navigation": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.7.tgz", + "integrity": "sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==", + "license": "MIT" + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -13090,17 +10922,49 @@ } }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/strip-ansi": { @@ -13136,12 +11000,16 @@ } }, "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/structured-headers": { @@ -13188,15 +11056,15 @@ } }, "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/supports-hyperlinks": { @@ -13212,27 +11080,6 @@ "node": ">=8" } }, - "node_modules/supports-hyperlinks/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -13246,9 +11093,9 @@ } }, "node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -13270,15 +11117,6 @@ "node": ">=18" } }, - "node_modules/temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -13296,9 +11134,9 @@ } }, "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -13334,40 +11172,19 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -13414,13 +11231,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -13429,35 +11246,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -13507,12 +11295,15 @@ } }, "node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ua-parser-js": { @@ -13542,18 +11333,18 @@ } }, "node_modules/undici": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", - "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -13596,18 +11387,6 @@ "node": ">=4" } }, - "node_modules/unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "license": "MIT", - "dependencies": { - "crypto-random-string": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -13618,9 +11397,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -13691,6 +11470,7 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -13760,13 +11540,10 @@ } }, "node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=8" - } + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" }, "node_modules/whatwg-fetch": { "version": "3.6.20", @@ -13798,11 +11575,14 @@ "node": ">=10" } }, - "node_modules/whatwg-url/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" + "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } }, "node_modules/which": { "version": "2.0.2", @@ -13841,9 +11621,9 @@ } }, "node_modules/wonka": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", - "integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", "license": "MIT" }, "node_modules/wrap-ansi": { @@ -13863,39 +11643,29 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=7.0.0" + "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -13916,16 +11686,16 @@ } }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -14011,9 +11781,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -14037,6 +11807,29 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js index 9319117..5fa36b5 100644 --- a/front/src/navigation/index.js +++ b/front/src/navigation/index.js @@ -17,13 +17,22 @@ import { CustomExercisesProvider } from '../context/CustomExercisesContext'; import { QuestProvider } from '../context/QuestContext'; import { UserProvider } from '../context/UserContext'; import { TutorialProvider } from '../context/TutorialContext'; -import { setupNotificationChannels } from '../services/notificationService'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { setupNotificationChannels, ensureDailyRemindersScheduled } from '../services/notificationService'; + +const NOTIF_ENABLED_KEY = 'athly:notif:enabled:v1'; export default function AppNavigator() { const { userToken, isLoading } = useAuth(); useEffect(() => { setupNotificationChannels(); + (async () => { + try { + const enabled = await AsyncStorage.getItem(NOTIF_ENABLED_KEY); + if (enabled === 'true') await ensureDailyRemindersScheduled(); + } catch (_) {} + })(); }, []); if (isLoading) { diff --git a/front/src/services/notificationService.js b/front/src/services/notificationService.js index dfd7a7c..4202e8d 100644 --- a/front/src/services/notificationService.js +++ b/front/src/services/notificationService.js @@ -2,9 +2,20 @@ import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -const DAILY_NOTIF_ID_KEY = 'athly:notif:daily_id:v1'; -const CHANNEL_ORANGE_ID = 'streak-orange'; -const CHANNEL_VIOLET_ID = 'streak-purple'; +const DAILY_NOTIF_IDS_KEY = 'athly:notif:daily_ids:v2'; +const CHANNEL_ORANGE_ID = 'streak-orange'; +const CHANNEL_VIOLET_ID = 'streak-purple'; +const CHANNEL_BIRTHDAY_ID = 'athly-birthday'; +const CHANNEL_EVENTS_ID = 'athly-events'; + +// Nombre de jours pour lesquels on pré-planifie un rappel quotidien. +// Chaque jour a son propre trigger DATE avec un contenu tiré indépendamment +// (contrairement à un trigger DAILY qui réutilise le même contenu figé à l'infini). +// iOS plafonne à 64 notifications locales en attente : on garde une marge pour +// les notifications contextuelles (anniversaire, coffre, ami, secousse). +const DAILY_BATCH_SIZE = 21; +// Seuil sous lequel on considère qu'il faut reconstituer le stock de rappels. +const REFILL_THRESHOLD = 5; const MESSAGES_ORANGE = [ { title: "La streak t'attend 🔥", body: "Tu es à une séance d'une meilleure version de toi. Allez, lance-toi !" }, @@ -53,6 +64,18 @@ export async function setupNotificationChannels() { lightColor: '#8B5CF6', vibrationPattern: [0, 400, 200, 400], }), + Notifications.setNotificationChannelAsync(CHANNEL_BIRTHDAY_ID, { + name: 'Anniversaire', + importance: Notifications.AndroidImportance.HIGH, + lightColor: '#FFD700', + vibrationPattern: [0, 300, 150, 300, 150, 300], + }), + Notifications.setNotificationChannelAsync(CHANNEL_EVENTS_ID, { + name: 'Événements Athly', + importance: Notifications.AndroidImportance.HIGH, + lightColor: '#22D3EE', + vibrationPattern: [0, 250, 250, 250], + }), ]); } @@ -66,6 +89,19 @@ function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; } +// Tire un {channel, msg} au hasard (50/50 sur le canal) en évitant de reproduire +// le même titre que `previous`, pour ne jamais avoir deux jours consécutifs identiques. +function pickDailyOccurrence(previous) { + let attempt; + for (let i = 0; i < 5; i++) { + const useOrange = Math.random() < 0.5; + const msg = pickRandom(useOrange ? MESSAGES_ORANGE : MESSAGES_VIOLET); + attempt = { channelId: useOrange ? CHANNEL_ORANGE_ID : CHANNEL_VIOLET_ID, msg }; + if (!previous || attempt.msg.title !== previous.msg.title) return attempt; + } + return attempt; +} + export async function fireTestNotification(type) { if (Platform.OS === 'web') return; const isOrange = type === 'orange'; @@ -86,37 +122,142 @@ export async function fireTestNotification(type) { }); } -export async function scheduleDailyReminder(hour = 18, minute = 0) { +/** + * Planifie une série de rappels quotidiens (un par jour, `count` jours), + * chacun avec un canal et un message tirés indépendamment. Contrairement à un + * trigger DAILY unique (contenu figé pour toujours), chaque occurrence a son + * propre contenu -> plus d'effet "perroquet". + */ +export async function scheduleDailyReminder(hour = 18, minute = 0, count = DAILY_BATCH_SIZE) { if (Platform.OS === 'web') return null; + + // Nettoie systématiquement toute planification précédente avant d'en créer + // une nouvelle, pour éviter l'accumulation de rappels dupliqués en arrière-plan. await cancelDailyReminder(); - const useOrange = Math.random() < 0.5; - const msg = pickRandom(useOrange ? MESSAGES_ORANGE : MESSAGES_VIOLET); - const id = await Notifications.scheduleNotificationAsync({ - content: { - title: msg.title, - body: msg.body, - sound: true, - ...(Platform.OS === 'android' && { - channelId: useOrange ? CHANNEL_ORANGE_ID : CHANNEL_VIOLET_ID, - }), - }, - trigger: { - type: Notifications.SchedulableTriggerInputTypes.DAILY, - hour, - minute, - }, - }); - try { await AsyncStorage.setItem(DAILY_NOTIF_ID_KEY, id); } catch (_) {} - return id; + + const now = new Date(); + const ids = []; + let previous = null; + + for (let dayOffset = 0; dayOffset < count; dayOffset++) { + const date = new Date(now); + date.setDate(date.getDate() + dayOffset); + date.setHours(hour, minute, 0, 0); + // Si l'heure du jour 0 est déjà passée, on démarre à demain. + if (date <= now) { + date.setDate(date.getDate() + 1); + } + + const occurrence = pickDailyOccurrence(previous); + previous = occurrence; + + const id = await Notifications.scheduleNotificationAsync({ + content: { + title: occurrence.msg.title, + body: occurrence.msg.body, + sound: true, + data: { type: 'daily_reminder' }, + ...(Platform.OS === 'android' && { channelId: occurrence.channelId }), + }, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.DATE, + date, + }, + }); + ids.push(id); + } + + try { + await AsyncStorage.setItem(DAILY_NOTIF_IDS_KEY, JSON.stringify({ hour, minute, ids })); + } catch (_) {} + + return ids; } -export async function cancelDailyReminder() { +/** + * À appeler au lancement de l'app (ou au retour au premier plan) : si le stock + * de rappels pré-planifiés commence à manquer, on le reconstitue. Le + * cancel-puis-replanifie de `scheduleDailyReminder` garantit qu'on ne double + * jamais les notifications en attente. + */ +export async function ensureDailyRemindersScheduled(hour = 18, minute = 0) { if (Platform.OS === 'web') return; try { - const id = await AsyncStorage.getItem(DAILY_NOTIF_ID_KEY); - if (id) { - await Notifications.cancelScheduledNotificationAsync(id); - await AsyncStorage.removeItem(DAILY_NOTIF_ID_KEY); + const raw = await AsyncStorage.getItem(DAILY_NOTIF_IDS_KEY); + const stored = raw ? JSON.parse(raw) : null; + const remaining = stored?.ids?.length ?? 0; + const sameSlot = stored && stored.hour === hour && stored.minute === minute; + + if (!sameSlot || remaining < REFILL_THRESHOLD) { + await scheduleDailyReminder(hour, minute); } + } catch (_) { + await scheduleDailyReminder(hour, minute); + } +} + +export async function cancelDailyReminder() { + if (Platform.OS === 'web') return; + try { + const raw = await AsyncStorage.getItem(DAILY_NOTIF_IDS_KEY); + const stored = raw ? JSON.parse(raw) : null; + const ids = stored?.ids ?? []; + await Promise.all(ids.map((id) => Notifications.cancelScheduledNotificationAsync(id))); + await AsyncStorage.removeItem(DAILY_NOTIF_IDS_KEY); } catch (_) {} } + +// ─── Notifications contextuelles V2 ─────────────────────────────────────── +// Déclenchées ponctuellement par l'app quand elle détecte l'événement +// correspondant (réponse API, websocket, etc.) — elles ne touchent pas au +// stock des rappels quotidiens gérés ci-dessus. + +async function fireImmediate({ title, body, channelId, data }) { + if (Platform.OS === 'web') return null; + return Notifications.scheduleNotificationAsync({ + content: { + title, + body, + sound: true, + data, + ...(Platform.OS === 'android' && { channelId }), + }, + trigger: null, // délivrance immédiate + }); +} + +export async function notifyBirthday(firstName) { + return fireImmediate({ + title: `🎂 Joyeux Anniversaire ${firstName} !`, + body: 'Ton coffre et ton trophée t\'attendent !', + channelId: CHANNEL_BIRTHDAY_ID, + data: { type: 'birthday' }, + }); +} + +export async function notifyChestAvailable() { + return fireImmediate({ + title: '📦 Nouvel effort récompensé !', + body: 'Un coffre est prêt à être ouvert.', + channelId: CHANNEL_EVENTS_ID, + data: { type: 'chest_available' }, + }); +} + +export async function notifyFriendInvite(pseudo) { + return fireImmediate({ + title: '⚡ Nouvelle invitation', + body: `${pseudo} veut devenir ton ami sur Athly. Accepte l'invitation !`, + channelId: CHANNEL_EVENTS_ID, + data: { type: 'friend_invite', pseudo }, + }); +} + +export async function notifyShake(pseudo) { + return fireImmediate({ + title: '🚨 BOUGE-TOI !', + body: `${pseudo} t'a secoué. Ne casse pas la Streak du groupe !`, + channelId: CHANNEL_EVENTS_ID, + data: { type: 'shake', pseudo }, + }); +} From d5d2fc97f9d058fc1914449ae648baf5df06aba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= <119584744+ClemLy@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:15:10 +0200 Subject: [PATCH 03/31] Delete .DS_Store --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 56f9ae74dacf8e846650e39b2f5d82c16db394be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKPfrs;6n_Inwji=Vi%2xt(2EHK5r~l(Ls=kN|3O%SMZmh-Zp+GcrrF(6k&yJP zi3g8<06%~yj$S-^_29)X;K7queX}#=PtmIovoD$Xy_xsk%)a05%Wfz@V5_Fo*hG&p!Ur}kN9eyim1C!J+3^K`drXPrEwhV#=2d z$#2OnJU#4M-l?EZ;;yBsJbgkq$g@t0#w}b2veKn_c`+1MK?jrM@z+rTC-A9r$ zfnCnfBEuQCk>G_2H~A~J=Odno$*!87XVQ|b?`*iHWxEShXF?)ni7a|HA1R#M zsF-<&mQvF{1dFNkVnewq6ctiJ!4o6V&?J)7M@tL!Wy+`>kEhc2rAdKdn1Op>z$Vn- z4ZMT*un(W$D}0BaBu4tl5E&+u2Xc6rn_3F^If55D|{{wDhkF6yZR^nQ&uAuHnDj|BnYnt%!k$f&YpD5}PyTbWBN}ttXP>XRU_i85Si0W6`QP2&n;T4G%w_Mk*R0wE1i6*2Hn8TbtbRQB8e From c11ccf09ae525f2ee01d3eefaca4ff2f2caceebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 11:37:45 +0200 Subject: [PATCH 04/31] feat: systeme anniversaire anti-triche complet avec recompense jour J, securite backend et animation confetti PWA --- back/controllers/reward.controller.js | 90 +++++- back/models/User.js | 3 + back/routes/reward.routes.js | 5 +- back/tests/reward.test.js | 101 +++++++ .../src/components/profile/BirthdatePicker.js | 283 ++++++++++++++++++ .../components/profile/BirthdayCelebration.js | 58 ++++ .../components/profile/BirthdayConfetti.js | 92 ++++++ front/src/components/profile/BirthdayModal.js | 140 +++++++++ front/src/navigation/index.js | 8 +- .../src/screens/Profile/EditProfileScreen.js | 26 +- front/src/services/reward.service.js | 19 ++ 11 files changed, 821 insertions(+), 4 deletions(-) create mode 100644 front/src/components/profile/BirthdatePicker.js create mode 100644 front/src/components/profile/BirthdayCelebration.js create mode 100644 front/src/components/profile/BirthdayConfetti.js create mode 100644 front/src/components/profile/BirthdayModal.js create mode 100644 front/src/services/reward.service.js diff --git a/back/controllers/reward.controller.js b/back/controllers/reward.controller.js index 28a9279..04c4491 100644 --- a/back/controllers/reward.controller.js +++ b/back/controllers/reward.controller.js @@ -16,6 +16,13 @@ const ACHIEVEMENT_CATALOG = { category: 'profile', hidden: true, }, + BIRTHDAY_CELEBRATED: { + id: 'BIRTHDAY_CELEBRATED', + name: 'Fêter son anniversaire', + description: "Vous avez réclamé votre cadeau d'anniversaire sur Athly.", + category: 'profile', + hidden: false, + }, // ── Collection (inventaire) ─────────────────────────────────────────────── FIRST_COMMON_ITEM: { @@ -82,6 +89,15 @@ function createError(message, statusCode = 400) { return err; } +// True si `date` tombe le même jour/mois que `birthdate`, quelle que soit l'année. +// Comparaison en UTC pour rester déterministe indépendamment du fuseau du serveur. +function isSameDayAndMonth(birthdate, date) { + return ( + birthdate.getUTCDate() === date.getUTCDate() && + birthdate.getUTCMonth() === date.getUTCMonth() + ); +} + // ─── checkAndUnlockAchievements ─────────────────────────────────────────────── /** * Vérifie l'état de l'utilisateur et débloque tous les trophées dont les @@ -110,8 +126,9 @@ async function checkAndUnlockAchievements(userId) { newlyUnlocked.push(achievementId); } - // ── Trophée anniversaire ─────────────────────────────────────────────────── + // ── Trophées anniversaire ────────────────────────────────────────────────── if (user.isBirthdateSet) tryUnlock('BIRTHDAY_SET'); + if (user.lastBirthdayRewardedYear != null) tryUnlock('BIRTHDAY_CELEBRATED'); // ── Trophées de collection : 1 item par rareté dans l'inventaire ────────── const RARITY_ACHIEVEMENTS = { @@ -221,6 +238,76 @@ exports.setBirthdate = async (req, res, next) => { } }; +// ───────────────────────────────────────────────────────────────────────────── +// checkBirthday POST /api/rewards/birthday/check +// ───────────────────────────────────────────────────────────────────────────── + +/** + * À appeler au login / au chargement de l'app. + * + * Si aujourd'hui est le jour de naissance de l'utilisateur et que le cadeau + * n'a pas encore été réclamé cette année civile : + * - Octroie 1 CHEST_KEY (cadeau de bienvenue, prépare la Brique II). + * - Débloque le trophée BIRTHDAY_CELEBRATED (prépare la Brique V). + * - Marque `lastBirthdayRewardedYear` avec l'année en cours. + * + * Idempotent : rejouée plusieurs fois le même jour, la récompense n'est + * accordée qu'une seule fois (contrôle sur lastBirthdayRewardedYear). + */ +exports.checkBirthday = async (req, res, next) => { + try { + const user = await User.findById(req.user.id); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + if (!user.isBirthdateSet || !user.birthdate) { + return res.status(200).json({ success: true, isBirthday: false, rewarded: false }); + } + + const now = new Date(); + const isBirthday = isSameDayAndMonth(user.birthdate, now); + + if (!isBirthday) { + return res.status(200).json({ success: true, isBirthday: false, rewarded: false }); + } + + const currentYear = now.getUTCFullYear(); + if (user.lastBirthdayRewardedYear === currentYear) { + return res.status(200).json({ + success: true, + isBirthday: true, + rewarded: false, + pseudo: user.pseudo || user.name || null, + }); + } + + // Cadeau de bienvenue : +1 CHEST_KEY + const existingKey = user.inventory.find((i) => i.itemType === 'CHEST_KEY'); + if (existingKey) { + existingKey.quantity += 1; + } else { + user.inventory.push({ itemType: 'CHEST_KEY', rarity: 'common', quantity: 1 }); + } + user.markModified('inventory'); + + user.lastBirthdayRewardedYear = currentYear; + await user.save(); + + // Déblocage des trophées (déclenche BIRTHDAY_CELEBRATED) + const newlyUnlocked = await checkAndUnlockAchievements(req.user.id); + + return res.status(200).json({ + success: true, + isBirthday: true, + rewarded: true, + chestKeyAdded: true, + newlyUnlocked, + pseudo: user.pseudo || user.name || null, + }); + } catch (err) { + next(err); + } +}; + // ───────────────────────────────────────────────────────────────────────────── // getUserAchievements GET /api/rewards/achievements // ───────────────────────────────────────────────────────────────────────────── @@ -306,3 +393,4 @@ exports.checkAchievements = async (req, res, next) => { exports.checkAndUnlockAchievements = checkAndUnlockAchievements; exports.ACHIEVEMENT_CATALOG = ACHIEVEMENT_CATALOG; exports.CATALOG_SIZE = CATALOG_SIZE; +exports.isSameDayAndMonth = isSameDayAndMonth; diff --git a/back/models/User.js b/back/models/User.js index 445156d..e430573 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -65,6 +65,9 @@ const UserSchema = new mongoose.Schema( // ── Profil physique ─────────────────────────────────────────────────────── birthdate: { type: Date }, isBirthdateSet: { type: Boolean, default: false }, // verrouille la modif après 1ère saisie + // Année (calendaire) où le cadeau d'anniversaire ("jour J") a été réclamé. + // Empêche de le réclamer plusieurs fois la même année. null = jamais fêté. + lastBirthdayRewardedYear: { type: Number, default: null }, age: { type: Number }, sexe: { type: String, enum: ["H", "F", "Autre"] }, poids: { type: Number }, diff --git a/back/routes/reward.routes.js b/back/routes/reward.routes.js index ef64573..2df2825 100644 --- a/back/routes/reward.routes.js +++ b/back/routes/reward.routes.js @@ -8,7 +8,10 @@ const reward = require('../controllers/reward.controller'); router.use(auth); // ── Profil / Anti-triche anniversaire ───────────────────────────────────────── -router.post('/birthdate', reward.setBirthdate); +router.post('/birthdate', reward.setBirthdate); + +// Jour J : à appeler au login / au chargement de l'app. +router.post('/birthday/check', reward.checkBirthday); // ── Trophées / Succès ───────────────────────────────────────────────────────── router.get('/achievements', reward.getUserAchievements); diff --git a/back/tests/reward.test.js b/back/tests/reward.test.js index 79b94f4..b24e0f7 100644 --- a/back/tests/reward.test.js +++ b/back/tests/reward.test.js @@ -357,4 +357,105 @@ describe("Système Récompenses & Trophées Athly — Briques I & V", () => { expect(res.statusCode).toBe(401); }); }); + + // ─────────────────────────────────────────────────────────────────────────── + // 4. checkBirthday — cadeau du "jour J" (une fois par an) + // ─────────────────────────────────────────────────────────────────────────── + describe("POST /api/rewards/birthday/check — checkBirthday", () => { + + it("✅ isBirthday: false si aucune date de naissance n'est renseignée", async () => { + const res = await request(app) + .post('/api/rewards/birthday/check') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.isBirthday).toBe(false); + expect(res.body.rewarded).toBe(false); + }); + + it("✅ isBirthday: false si la date de naissance n'est pas aujourd'hui", async () => { + // Anniversaire décalé de 6 mois : garantit un mois différent d'aujourd'hui + const notToday = new Date(); + notToday.setUTCFullYear(notToday.getUTCFullYear() - 20); + notToday.setUTCMonth((notToday.getUTCMonth() + 6) % 12); + + await request(app) + .post('/api/rewards/birthdate') + .set('Authorization', `Bearer ${alice.token}`) + .send({ birthdate: notToday.toISOString() }); + + const res = await request(app) + .post('/api/rewards/birthday/check') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.isBirthday).toBe(false); + expect(res.body.rewarded).toBe(false); + }); + + it("✅ Accorde le cadeau (CHEST_KEY + trophée) le jour J, la première fois de l'année", async () => { + const today = new Date(); + const birthdateToday = new Date(Date.UTC(today.getUTCFullYear() - 25, today.getUTCMonth(), today.getUTCDate())); + + await request(app) + .post('/api/rewards/birthdate') + .set('Authorization', `Bearer ${alice.token}`) + .send({ birthdate: birthdateToday.toISOString() }); + + const beforeUser = await User.findById(alice.userId); + const beforeQty = beforeUser.inventory.find((i) => i.itemType === 'CHEST_KEY')?.quantity || 0; + + const res = await request(app) + .post('/api/rewards/birthday/check') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.isBirthday).toBe(true); + expect(res.body.rewarded).toBe(true); + expect(res.body.chestKeyAdded).toBe(true); + expect(res.body.newlyUnlocked).toContain('BIRTHDAY_CELEBRATED'); + + const updatedUser = await User.findById(alice.userId); + const afterQty = updatedUser.inventory.find((i) => i.itemType === 'CHEST_KEY')?.quantity || 0; + expect(afterQty).toBe(beforeQty + 1); + expect(updatedUser.lastBirthdayRewardedYear).toBe(today.getUTCFullYear()); + + const trophy = updatedUser.achievements.find((a) => a.achievementId === 'BIRTHDAY_CELEBRATED'); + expect(trophy).toBeDefined(); + }); + + it("✅ Idempotent : rejouée le même jour, le cadeau n'est pas raccordé une deuxième fois", async () => { + const today = new Date(); + const birthdateToday = new Date(Date.UTC(today.getUTCFullYear() - 25, today.getUTCMonth(), today.getUTCDate())); + + await request(app) + .post('/api/rewards/birthdate') + .set('Authorization', `Bearer ${alice.token}`) + .send({ birthdate: birthdateToday.toISOString() }); + + await request(app) + .post('/api/rewards/birthday/check') + .set('Authorization', `Bearer ${alice.token}`); + + const midUser = await User.findById(alice.userId); + const midQty = midUser.inventory.find((i) => i.itemType === 'CHEST_KEY')?.quantity || 0; + + const res = await request(app) + .post('/api/rewards/birthday/check') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.isBirthday).toBe(true); + expect(res.body.rewarded).toBe(false); + + const finalUser = await User.findById(alice.userId); + const finalQty = finalUser.inventory.find((i) => i.itemType === 'CHEST_KEY')?.quantity || 0; + expect(finalQty).toBe(midQty); + }); + + it("❌ 401 sans token", async () => { + const res = await request(app).post('/api/rewards/birthday/check'); + expect(res.statusCode).toBe(401); + }); + }); }); diff --git a/front/src/components/profile/BirthdatePicker.js b/front/src/components/profile/BirthdatePicker.js new file mode 100644 index 0000000..cb96974 --- /dev/null +++ b/front/src/components/profile/BirthdatePicker.js @@ -0,0 +1,283 @@ +import React, { useState, useMemo } from 'react'; +import { View, Text, StyleSheet, Modal, ScrollView, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +const MONTHS = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc']; + +function daysInMonth(month, year) { + return new Date(year ?? 2024, month + 1, 0).getDate(); +} + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +// ─── BirthdatePicker ────────────────────────────────────────────────────────── +// Sélecteur de date de naissance en 3 rangées de puces scrollables (jour / mois +// / année). Aucune dépendance native → identique sur mobile et Web/PWA. +// +// Anti-triche : une fois `value` renseigné, le champ est verrouillé (lecture +// seule) — la modification se fait exclusivement côté backend (setBirthdate), +// qui refuse toute écriture si isBirthdateSet est déjà true. +// +// Props : +// value Date | null — date déjà enregistrée en base (verrouille le champ) +// onConfirm (Date) => Promise — appelé après la confirmation finale ; +// doit re-throw en cas d'échec pour garder la modale ouverte. + +export default function BirthdatePicker({ value, onConfirm }) { + const locked = !!value; + const [modalOpen, setModalOpen] = useState(false); + const [step, setStep] = useState('pick'); // 'pick' | 'confirm' + const [day, setDay] = useState(null); + const [month, setMonth] = useState(null); // 0-11 + const [year, setYear] = useState(null); + const [saving, setSaving] = useState(false); + + const currentYear = new Date().getFullYear(); + const years = useMemo( + () => Array.from({ length: 101 }, (_, i) => currentYear - i), + [currentYear], + ); + const maxDay = daysInMonth(month ?? 0, year); + const days = useMemo(() => Array.from({ length: maxDay }, (_, i) => i + 1), [maxDay]); + + const canContinue = day != null && month != null && year != null; + + const openPicker = () => { + if (locked) return; + setDay(null); + setMonth(null); + setYear(null); + setStep('pick'); + setModalOpen(true); + }; + + const handleContinue = () => { + if (!canContinue) return; + setDay((d) => Math.min(d, daysInMonth(month, year))); + setStep('confirm'); + }; + + const handleConfirm = async () => { + setSaving(true); + try { + const clampedDay = Math.min(day, daysInMonth(month, year)); + const dateObj = new Date(Date.UTC(year, month, clampedDay)); + await onConfirm(dateObj); + setModalOpen(false); + } catch (_) { + // La modale reste ouverte (étape confirmation) pour permettre un nouvel essai. + // L'erreur est déjà affichée par l'appelant (toast). + } finally { + setSaving(false); + } + }; + + const formattedValue = value + ? `${pad2(value.getUTCDate())}/${pad2(value.getUTCMonth() + 1)}/${value.getUTCFullYear()}` + : null; + const formattedPending = canContinue ? `${pad2(day)}/${pad2(month + 1)}/${year}` : '—'; + + return ( + <> + + {locked && ( + + )} + + {formattedValue || 'Choisir une date'} + + + + !saving && setModalOpen(false)} + > + + + {step === 'pick' ? ( + <> + Date de naissance + + Cette date ne pourra plus être modifiée après validation. + + + String(d)} /> + i)} selected={month} onSelect={setMonth} format={(i) => MONTHS[i]} /> + String(y)} /> + + + Continuer + + setModalOpen(false)} activeOpacity={0.75}> + Annuler + + + ) : ( + <> + + + + Confirmer ta date de naissance + {formattedPending} + + Attention, cette date ne pourra plus être modifiée par la suite. Vérifie-la avant de valider. + + + + {saving ? 'Enregistrement…' : 'Valider définitivement'} + + setStep('pick')} disabled={saving} activeOpacity={0.75}> + Modifier + + + )} + + + + + ); +} + +function ChipScroll({ label, items, selected, onSelect, format }) { + return ( + + {label} + + {items.map((item) => { + const isSel = selected === item; + return ( + onSelect(item)} + activeOpacity={0.8} + > + {format(item)} + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + // ── Trigger (dans une SettingsRow) ─────────────────────────────────────────── + trigger: { flexDirection: 'row', alignItems: 'center' }, + triggerTxt: { color: Colors.textPrimary, fontSize: 14, fontWeight: '500' }, + triggerTxtLocked:{ color: Colors.textMuted }, + + // ── Modal ───────────────────────────────────────────────────────────────────── + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 20, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.09)', + padding: 24, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + title: { + color: Colors.textPrimary, + fontSize: 17, + fontWeight: '800', + letterSpacing: -0.2, + marginBottom: 8, + textAlign: 'center', + }, + subtitle: { + color: Colors.textSecondary, + fontSize: 12.5, + lineHeight: 18, + textAlign: 'center', + marginBottom: 18, + }, + confirmDate: { + color: Colors.primary, + fontSize: 26, + fontWeight: '800', + letterSpacing: 0.5, + marginBottom: 10, + }, + warnIconWrap: { + width: 56, + height: 56, + borderRadius: 18, + backgroundColor: 'rgba(245,158,11,0.10)', + borderWidth: 1, + borderColor: 'rgba(245,158,11,0.28)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 14, + }, + + // ── Chip scrollers ─────────────────────────────────────────────────────────── + chipScrollWrap: { width: '100%', marginBottom: 14 }, + chipScrollLabel: { color: Colors.textMuted, fontSize: 11, fontWeight: '700', letterSpacing: 0.6, marginBottom: 6 }, + chipScrollContent: { gap: 6, paddingRight: 4 }, + chip: { + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 9, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.09)', + backgroundColor: 'rgba(255,255,255,0.04)', + }, + chipSel: { backgroundColor: Colors.primary, borderColor: Colors.primary }, + chipTxt: { color: Colors.textSecondary, fontSize: 13, fontWeight: '600' }, + chipTxtSel: { color: '#fff' }, + + // ── Boutons ─────────────────────────────────────────────────────────────────── + primaryBtn: { + width: '100%', + height: 50, + borderRadius: 13, + backgroundColor: Colors.primary, + justifyContent: 'center', + alignItems: 'center', + marginTop: 4, + marginBottom: 10, + shadowColor: Colors.primary, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.35, + shadowRadius: 12, + elevation: 6, + }, + primaryBtnDisabled: { opacity: 0.4 }, + primaryBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + secondaryBtn: { width: '100%', height: 42, borderRadius: 13, justifyContent: 'center', alignItems: 'center' }, + secondaryBtnTxt: { color: Colors.textMuted, fontSize: 14, fontWeight: '500' }, +}); diff --git a/front/src/components/profile/BirthdayCelebration.js b/front/src/components/profile/BirthdayCelebration.js new file mode 100644 index 0000000..8b83432 --- /dev/null +++ b/front/src/components/profile/BirthdayCelebration.js @@ -0,0 +1,58 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useAuth } from '../../context/AuthContext'; +import { checkBirthday } from '../../services/reward.service'; +import BirthdayModal from './BirthdayModal'; + +const SHOWN_KEY = 'athly:birthday:shown_date:v1'; + +function todayKey() { + const now = new Date(); + return `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; +} + +// ─── BirthdayCelebration ────────────────────────────────────────────────────── +// À monter une fois dans l'arbre authentifié (AppNavigator). Vérifie au +// démarrage si c'est le jour d'anniversaire de l'utilisateur et affiche la +// modale festive + confettis (BirthdayModal). Dédupliqué par jour via +// AsyncStorage pour ne pas rejouer l'animation à chaque changement d'écran — +// seulement une fois par ouverture de l'app le jour J. + +export default function BirthdayCelebration() { + const { userToken } = useAuth(); + const [state, setState] = useState({ visible: false, pseudo: null, rewarded: false }); + + useEffect(() => { + if (!userToken) return; + + (async () => { + try { + const already = await AsyncStorage.getItem(SHOWN_KEY); + if (already === todayKey()) return; + + const res = await checkBirthday(); + if (!res?.isBirthday) return; + + await AsyncStorage.setItem(SHOWN_KEY, todayKey()); + setState({ visible: true, pseudo: res.pseudo || null, rewarded: !!res.rewarded }); + } catch (_) { + // Silencieux : ne doit jamais bloquer le démarrage de l'app. + } + })(); + }, [userToken]); + + const handleClose = useCallback(() => { + setState((s) => ({ ...s, visible: false })); + }, []); + + if (!state.visible) return null; + + return ( + + ); +} diff --git a/front/src/components/profile/BirthdayConfetti.js b/front/src/components/profile/BirthdayConfetti.js new file mode 100644 index 0000000..d8f74c3 --- /dev/null +++ b/front/src/components/profile/BirthdayConfetti.js @@ -0,0 +1,92 @@ +import React, { useRef, useEffect } from 'react'; +import { View, Animated, StyleSheet, Dimensions } from 'react-native'; +import { Colors } from '../../constants/theme'; + +// ─── BirthdayConfetti ───────────────────────────────────────────────────────── +// Pluie de confettis en surimpression pour la modale d'anniversaire. +// Même approche que EmberParticles.js (Animated + useNativeDriver), mais joue +// une seule fois (pas de boucle) quand `active` passe à true. + +const { height: SCREEN_H } = Dimensions.get('window'); +const CONFETTI_COUNT = 32; +const PALETTE = [Colors.rankViolet, Colors.gold, Colors.primary, Colors.success, Colors.legendAccent]; + +function randomBetween(min, max) { + return min + Math.random() * (max - min); +} + +export default function BirthdayConfetti({ active = false }) { + const pieces = useRef( + Array.from({ length: CONFETTI_COUNT }, (_, i) => ({ + fall: new Animated.Value(-40), + spin: new Animated.Value(0), + opacity: new Animated.Value(0), + leftPct: Math.random() * 100, + size: randomBetween(6, 12), + color: PALETTE[i % PALETTE.length], + square: Math.random() > 0.5, + duration: randomBetween(2600, 4200), + delay: randomBetween(0, 900), + })), + ).current; + + useEffect(() => { + if (!active) { + pieces.forEach((p) => { + p.fall.setValue(-40); + p.spin.setValue(0); + p.opacity.setValue(0); + }); + return; + } + + const animations = pieces.map((p) => { + p.fall.setValue(-40); + p.spin.setValue(0); + p.opacity.setValue(0); + return Animated.sequence([ + Animated.delay(p.delay), + Animated.parallel([ + Animated.timing(p.fall, { toValue: SCREEN_H + 40, duration: p.duration, useNativeDriver: true }), + Animated.timing(p.spin, { toValue: 1, duration: p.duration, useNativeDriver: true }), + Animated.sequence([ + Animated.timing(p.opacity, { toValue: 1, duration: 250, useNativeDriver: true }), + Animated.timing(p.opacity, { toValue: 1, duration: Math.max(p.duration - 700, 0), useNativeDriver: true }), + Animated.timing(p.opacity, { toValue: 0, duration: 450, useNativeDriver: true }), + ]), + ]), + ]); + }); + + Animated.parallel(animations).start(); + + return () => animations.forEach((a) => a.stop()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); + + if (!active) return null; + + return ( + + {pieces.map((p, i) => { + const rotate = p.spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '900deg'] }); + return ( + + ); + })} + + ); +} diff --git a/front/src/components/profile/BirthdayModal.js b/front/src/components/profile/BirthdayModal.js new file mode 100644 index 0000000..7257019 --- /dev/null +++ b/front/src/components/profile/BirthdayModal.js @@ -0,0 +1,140 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import BirthdayConfetti from './BirthdayConfetti'; + +// ─── BirthdayModal ──────────────────────────────────────────────────────────── +// Modale festive violette déclenchée à l'ouverture de l'app le jour de +// l'anniversaire de l'utilisateur (voir BirthdayCelebration.js). +// +// Props : +// visible bool +// pseudo string | null +// rewarded bool — true la première ouverture du jour (cadeau tout juste accordé) +// onClose () => void + +export default function BirthdayModal({ visible, pseudo, rewarded, onClose }) { + return ( + + + + + + + 🎂 + + + + Joyeux Anniversaire{pseudo ? ` ${pseudo}` : ''} ! 🎉 + + + + {rewarded + ? "Toute l'équipe Athly te souhaite une excellente année. Ton coffre et ton trophée t'attendent dans ton inventaire !" + : "Toute l'équipe Athly te souhaite une excellente année. Profite bien de ta journée !"} + + + {rewarded && ( + + + + +1 Coffre + + + + Trophée débloqué + + + )} + + + Merci Athly ! + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: 'rgba(139,92,246,0.35)', + padding: 28, + alignItems: 'center', + shadowColor: Colors.rankViolet, + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.45, + shadowRadius: 32, + elevation: 20, + }, + iconWrap: { + width: 72, + height: 72, + borderRadius: 24, + backgroundColor: 'rgba(139,92,246,0.14)', + borderWidth: 1, + borderColor: 'rgba(139,92,246,0.35)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 18, + }, + iconEmoji: { fontSize: 34 }, + title: { + color: Colors.textPrimary, + fontSize: 19, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 12, + textAlign: 'center', + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 20, + }, + rewardRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 22, + }, + rewardChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.09)', + }, + rewardChipTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600' }, + closeBtn: { + width: '100%', + height: 50, + borderRadius: 13, + backgroundColor: Colors.rankViolet, + justifyContent: 'center', + alignItems: 'center', + shadowColor: Colors.rankViolet, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + closeBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js index 9319117..9271a3e 100644 --- a/front/src/navigation/index.js +++ b/front/src/navigation/index.js @@ -18,6 +18,7 @@ import { QuestProvider } from '../context/QuestContext'; import { UserProvider } from '../context/UserContext'; import { TutorialProvider } from '../context/TutorialContext'; import { setupNotificationChannels } from '../services/notificationService'; +import BirthdayCelebration from '../components/profile/BirthdayCelebration'; export default function AppNavigator() { const { userToken, isLoading } = useAuth(); @@ -47,7 +48,12 @@ export default function AppNavigator() { - {userToken === null ? : } + {userToken === null ? : ( + <> + + + + )} diff --git a/front/src/screens/Profile/EditProfileScreen.js b/front/src/screens/Profile/EditProfileScreen.js index 6e2d912..0b49faa 100644 --- a/front/src/screens/Profile/EditProfileScreen.js +++ b/front/src/screens/Profile/EditProfileScreen.js @@ -13,6 +13,8 @@ import { Colors } from '../../constants/theme'; import API from '../../api/api'; import { useUser } from '../../context/UserContext'; import { useToast } from '../../context/ToastContext'; +import { setBirthdate } from '../../services/reward.service'; +import BirthdatePicker from '../../components/profile/BirthdatePicker'; // ─── EditProfileScreen ──────────────────────────────────────────────────────── // Pas de header custom : on configure navigation.setOptions via useLayoutEffect. @@ -138,6 +140,22 @@ export default function EditProfileScreen({ navigation }) { handleUpdateRef.current = handleUpdate; + // Anti-triche : setBirthdate est un endpoint dédié (pas /users/me), verrouillé + // côté backend dès la première saisie. On re-throw en cas d'échec pour que + // BirthdatePicker garde sa modale ouverte et permette un nouvel essai. + const handleBirthdateConfirm = useCallback(async (dateObj) => { + try { + await setBirthdate(dateObj.toISOString()); + await refetchUser(); + showToast('Date de naissance enregistrée. Ton coffre est dans ton inventaire ! 🎁', 'success'); + } catch (error) { + if (error.isSessionExpired) throw error; + const msg = error.data?.message || error.message || 'Erreur réseau. Réessaie dans un instant.'; + showToast(msg, 'error'); + throw error; + } + }, [refetchUser, showToast]); + // Configure native header with "Sauver" button — re-runs when loading changes useLayoutEffect(() => { navigation.setOptions({ @@ -227,7 +245,7 @@ export default function EditProfileScreen({ navigation }) { keyboardType="decimal-pad" /> - + + + + {/* ═══ PROGRAMME ═════════════════════════════════════════════════════ */} diff --git a/front/src/services/reward.service.js b/front/src/services/reward.service.js new file mode 100644 index 0000000..cc4b60e --- /dev/null +++ b/front/src/services/reward.service.js @@ -0,0 +1,19 @@ +import API from '../api/api'; + +// Enregistre la date de naissance (une seule fois — verrouillée côté backend). +export async function setBirthdate(dateISO) { + const res = await API.post('/rewards/birthdate', { birthdate: dateISO }); + return res.data; +} + +// À appeler au login / au chargement de l'app : vérifie si c'est le jour J +// et accorde le cadeau d'anniversaire (une fois par an). +export async function checkBirthday() { + const res = await API.post('/rewards/birthday/check'); + return res.data; +} + +export async function getAchievements() { + const res = await API.get('/rewards/achievements'); + return res.data; +} From 2d098e6a288b9772c0ba0e5d80529c7c5a84625f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 12:18:09 +0200 Subject: [PATCH 05/31] =?UTF-8?q?chore:=20refonte=20architecture=20enterpr?= =?UTF-8?q?ise=20-=20securite=20renforc=C3=A9e,=20isolation=20des=20tests?= =?UTF-8?q?=20en=20memoire,=20correction=20de=20race=20conditions=20et=20P?= =?UTF-8?q?WA=20offline-first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 30 +- back/.env.example | 10 + back/app.js | 64 ++- back/controllers/inventory.controller.js | 126 ++++-- back/jest.config.js | 7 +- back/middleware/auth.middleware.js | 47 +-- back/middleware/rateLimit.middleware.js | 47 +++ back/middleware/sanitize.middleware.js | 37 ++ back/package-lock.json | 409 ++++++++++++++++++- back/package.json | 4 +- back/server.js | 67 ++- back/tests/globalSetup.js | 22 + back/tests/globalTeardown.js | 7 + back/tests/inventory.test.js | 27 ++ docs/ARCHITECTURE-SECURITE.md | 75 ++++ front/App.js | 19 +- front/public/sw.js | 34 +- front/src/api/api.js | 57 ++- front/src/components/common/ErrorBoundary.js | 88 ++++ front/src/context/AuthContext.js | 4 +- 20 files changed, 1059 insertions(+), 122 deletions(-) create mode 100644 back/middleware/rateLimit.middleware.js create mode 100644 back/middleware/sanitize.middleware.js create mode 100644 back/tests/globalSetup.js create mode 100644 back/tests/globalTeardown.js create mode 100644 docs/ARCHITECTURE-SECURITE.md create mode 100644 front/src/components/common/ErrorBoundary.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d836fe9..3637654 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,24 +42,15 @@ jobs: # Lint ESLint + vérification syntaxique + suite de tests Jest/Supertest # ──────────────────────────────────────────────────────────────────────────── backend-validation: - name: "Backend — Lint & Tests" + name: "Backend — Lint, Audit & Tests" needs: changes if: needs.changes.outputs.backend == 'true' runs-on: ubuntu-latest timeout-minutes: 15 - # Base de données disponible pendant tous les steps du job - services: - mongodb: - image: mongo:7 - ports: - - 27017:27017 - options: >- - --health-cmd "mongosh --eval 'db.adminCommand({ ping: 1 })' --quiet" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - --health-start-period 20s + # Plus besoin de service MongoDB : la suite Jest démarre sa propre + # instance en mémoire (mongodb-memory-server) — environnement 100% isolé, + # identique en CI et en local. # Tous les `run:` s'exécutent dans back/ par défaut defaults: @@ -88,13 +79,18 @@ jobs: - name: Syntax check — node --check run: node --check server.js - # ── Tests ───────────────────────────────────────────────────────────── + # ── Sécurité des dépendances ────────────────────────────────────────── + # Bloque le merge si une dépendance DE PRODUCTION a une vulnérabilité + # high/critical. Les devDependencies (outillage) ne bloquent pas. + - name: Security audit — npm audit (prod, high+) + run: npm audit --omit=dev --audit-level=high + + # ── Tests (MongoDB en mémoire via jest globalSetup) ─────────────────── - name: Run test suite run: npm test env: NODE_ENV: test PORT: 5000 - MONGO_URI: mongodb://localhost:27017/athly-ci # Utilise le secret GitHub si configuré, sinon une valeur de repli CI-only JWT_SECRET: ${{ secrets.JWT_SECRET || 'ci-test-secret-do-not-use-in-prod' }} @@ -131,6 +127,10 @@ jobs: - name: Install dependencies (--legacy-peer-deps) run: npm ci --legacy-peer-deps + # ── Sécurité des dépendances ────────────────────────────────────────── + - name: Security audit — npm audit (prod, high+) + run: npm audit --omit=dev --audit-level=high + - name: Build PWA — expo export --platform web run: npm run build:web env: diff --git a/back/.env.example b/back/.env.example index d50c9a4..aa5a73b 100644 --- a/back/.env.example +++ b/back/.env.example @@ -58,3 +58,13 @@ SMTP_FROM=votre.adresse.verifiee@domaine.com # ------------------------------ # development | production NODE_ENV=development + + +# ------------------------------ +# 🌐 CORS (production) +# ------------------------------ +# Allowlist des origines web autorisées, séparées par des virgules. +# Les requêtes sans header Origin (app mobile native) ne sont pas concernées. +# Non définie → CORS permissif (dev uniquement, warning logué en production). +# Exemple : CORS_ORIGINS=https://athly.app,https://www.athly.app +CORS_ORIGINS= diff --git a/back/app.js b/back/app.js index 7fbd01c..eb045cf 100644 --- a/back/app.js +++ b/back/app.js @@ -1,5 +1,8 @@ const express = require("express"); const cors = require("cors"); +const helmet = require("helmet"); +const morgan = require("morgan"); +const config = require("./config/env"); // --- Importation des routes --- const authRoutes = require("./routes/auth.routes"); @@ -15,13 +18,64 @@ const referralRoutes = require("./routes/referral.routes"); // --- Importation des middlewares --- const errorMiddleware = require("./middleware/error.middleware"); const notFoundMiddleware = require("./middleware/not-found.middleware"); +const sanitizeMiddleware = require("./middleware/sanitize.middleware"); +const { globalLimiter, authLimiter } = require("./middleware/rateLimit.middleware"); const app = express(); -// --- Middlewares Globaux --- -app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +// Render/reverse-proxy : nécessaire pour que req.ip soit la vraie IP client +// (sinon le rate-limiting par IP throttlerait tous les clients ensemble). +app.set("trust proxy", 1); + +// --- Sécurité des headers HTTP (Helmet) --- +app.use(helmet()); + +// --- CORS --- +// CORS_ORIGINS (csv) définit l'allowlist. Les requêtes sans header Origin +// (app mobile native, curl, monitoring) ne sont pas soumises à CORS. +// Sans variable définie : comportement permissif (dev) + warning en prod. +const allowedOrigins = (process.env.CORS_ORIGINS || "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + +if (allowedOrigins.length === 0 && config.nodeEnv === "production") { + console.warn("⚠️ CORS_ORIGINS non définie : CORS permissif en production."); +} + +app.use(cors( + allowedOrigins.length > 0 + ? { + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) return callback(null, true); + return callback(new Error("Origine non autorisée par la politique CORS.")); + }, + } + : {} +)); + +// --- Parsing avec limite de payload (anti-DoS par gros body) --- +app.use(express.json({ limit: "1mb" })); +app.use(express.urlencoded({ extended: true, limit: "1mb" })); + +// --- Assainissement anti-injection NoSQL (après parsing, avant les routes) --- +app.use(sanitizeMiddleware); + +// --- Logs d'accès (désactivés pendant les tests) --- +if (config.nodeEnv !== "test") { + app.use(morgan(config.nodeEnv === "production" ? "combined" : "dev")); +} + +// --- Réponses API jamais mises en cache par les intermédiaires --- +// (données utilisateur derrière auth — le cache offline est géré côté client) +app.use("/api", (req, res, next) => { + res.set("Cache-Control", "no-store"); + next(); +}); + +// --- Rate limiting --- +app.use("/api", globalLimiter); +app.use("/api/auth", authLimiter); // Route de santé pour le CI/CD app.get("/health", (req, res) => { @@ -46,4 +100,4 @@ app.use(notFoundMiddleware); // Middleware d'erreurs app.use(errorMiddleware); -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/back/controllers/inventory.controller.js b/back/controllers/inventory.controller.js index 2b2a6da..b51a81a 100644 --- a/back/controllers/inventory.controller.js +++ b/back/controllers/inventory.controller.js @@ -33,29 +33,71 @@ function getRankForLevel(level) { } /** - * Ajoute un item à l'inventaire en incrémentant la quantité si déjà présent. - * Modifie le tableau en place. + * Consomme atomiquement 1 unité d'un item. + * + * Anti race-condition (double-spend) : le filtre conditionnel garantit que le + * décrément n'a lieu que si la quantité est encore >= 1 AU MOMENT de l'écriture. + * Deux requêtes simultanées sur la dernière unité : une seule matche le filtre, + * l'autre reçoit null — impossible de dépenser deux fois le même objet. + * + * @returns Le document User APRÈS décrément, ou null si non possédé + * (ou si extraFilter ne matche pas). */ -function addToInventory(inventory, itemType, rarity, quantity = 1) { - const existing = inventory.find((i) => i.itemType === itemType); - if (existing) { - existing.quantity += quantity; - } else { - inventory.push({ itemType, rarity, quantity }); - } +async function consumeItemAtomic(userId, itemType, extraFilter = {}) { + return User.findOneAndUpdate( + { + _id: userId, + inventory: { $elemMatch: { itemType, quantity: { $gte: 1 } } }, + ...extraFilter, + }, + { $inc: { 'inventory.$[elem].quantity': -1 } }, + { + returnDocument: 'after', + arrayFilters: [{ 'elem.itemType': itemType }], + }, + ); +} + +/** + * Ajoute atomiquement 1 unité d'un item ($inc si l'entrée existe, sinon $push + * gardé par $ne pour éviter un double-push concurrent). + * @returns Le document User APRÈS ajout. + */ +async function addItemAtomic(userId, itemType, rarity) { + const incremented = await User.findOneAndUpdate( + { _id: userId, 'inventory.itemType': itemType }, + { $inc: { 'inventory.$.quantity': 1 } }, + { returnDocument: 'after' }, + ); + if (incremented) return incremented; + + const pushed = await User.findOneAndUpdate( + { _id: userId, 'inventory.itemType': { $ne: itemType } }, + { $push: { inventory: { itemType, rarity, quantity: 1 } } }, + { returnDocument: 'after' }, + ); + if (pushed) return pushed; + + // Course perdue contre un $push concurrent du même itemType : on retombe + // sur le $inc, qui matche forcément maintenant. + return User.findOneAndUpdate( + { _id: userId, 'inventory.itemType': itemType }, + { $inc: { 'inventory.$.quantity': 1 } }, + { returnDocument: 'after' }, + ); } /** - * Consomme `quantity` unités d'un item dans l'inventaire. - * Supprime l'entrée si la quantité tombe à 0. - * Retourne false si l'item est introuvable ou en quantité insuffisante. + * Purge les entrées d'inventaire tombées à 0 (comportement historique : + * une entrée épuisée disparaît de l'inventaire). + * @returns Le document User APRÈS purge. */ -function consumeFromInventory(inventory, itemType, quantity = 1) { - const idx = inventory.findIndex((i) => i.itemType === itemType); - if (idx === -1 || inventory[idx].quantity < quantity) return false; - inventory[idx].quantity -= quantity; - if (inventory[idx].quantity === 0) inventory.splice(idx, 1); - return true; +async function purgeEmptyEntries(userId) { + return User.findOneAndUpdate( + { _id: userId }, + { $pull: { inventory: { quantity: { $lte: 0 } } } }, + { returnDocument: 'after' }, + ); } // Effets des consommables — chaque fonction modifie user en place @@ -94,32 +136,31 @@ const VALID_USE_ITEMS = Object.keys(ITEM_EFFECTS); */ exports.openChest = async (req, res, next) => { try { - const user = await User.findById(req.user.id); - if (!user) return next(createError('Utilisateur introuvable.', 404)); - - if (user.level < MIN_LEVEL_FOR_CHEST) { - return next(createError("Fonctionnalité bloquée jusqu'au niveau 11.", 403)); - } + // Consommation atomique : clé décrémentée UNIQUEMENT si possédée ET niveau + // suffisant, en une seule écriture — aucune fenêtre de double-spend. + const afterConsume = await consumeItemAtomic(req.user.id, 'CHEST_KEY', { + level: { $gte: MIN_LEVEL_FOR_CHEST }, + }); - const hasKey = user.inventory.some((i) => i.itemType === 'CHEST_KEY' && i.quantity > 0); - if (!hasKey) { + if (!afterConsume) { + // Diagnostic du refus pour renvoyer l'erreur historique appropriée. + const user = await User.findById(req.user.id).select('level inventory'); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + if (user.level < MIN_LEVEL_FOR_CHEST) { + return next(createError("Fonctionnalité bloquée jusqu'au niveau 11.", 403)); + } return next(createError('Aucun coffre disponible dans votre inventaire.', 400)); } - consumeFromInventory(user.inventory, 'CHEST_KEY'); - const drawnItem = drawChestItem(); - addToInventory(user.inventory, drawnItem.itemType, drawnItem.rarity); - - // markModified nécessaire : Mongoose ne détecte pas les mutations des tableaux de sous-documents - user.markModified('inventory'); - await user.save(); + await addItemAtomic(req.user.id, drawnItem.itemType, drawnItem.rarity); + const finalUser = await purgeEmptyEntries(req.user.id); return res.status(200).json({ success: true, message: 'Coffre ouvert !', drawnItem, - inventory: user.inventory, + inventory: finalUser.inventory, }); } catch (err) { next(err); @@ -151,19 +192,22 @@ exports.useItem = async (req, res, next) => { )); } - const user = await User.findById(req.user.id); - if (!user) return next(createError('Utilisateur introuvable.', 404)); + // Consommation atomique : même garde anti double-spend que openChest. + const user = await consumeItemAtomic(req.user.id, itemType); - const consumed = consumeFromInventory(user.inventory, itemType); - if (!consumed) { + if (!user) { + const exists = await User.exists({ _id: req.user.id }); + if (!exists) return next(createError('Utilisateur introuvable.', 404)); return next(createError('Vous ne possédez pas cet objet.', 400)); } + // L'effet ne touche que des champs scalaires (xp, level, rank, streakGels) : + // save() n'écrit que ces chemins, sans réécrire l'inventaire déjà à jour. ITEM_EFFECTS[itemType](user); - - user.markModified('inventory'); await user.save(); + const finalUser = await purgeEmptyEntries(req.user.id); + return res.status(200).json({ success: true, message: "Objet utilisé avec succès.", @@ -172,7 +216,7 @@ exports.useItem = async (req, res, next) => { level: user.level, rank: user.rank, streakGels: user.streakGels, - inventory: user.inventory, + inventory: finalUser.inventory, }, }); } catch (err) { diff --git a/back/jest.config.js b/back/jest.config.js index a0545ac..3b5ea30 100644 --- a/back/jest.config.js +++ b/back/jest.config.js @@ -1,3 +1,8 @@ module.exports = { testTimeout: 30000, -}; \ No newline at end of file + // Base MongoDB en mémoire : les tests ne touchent jamais une vraie base + globalSetup: '/tests/globalSetup.js', + globalTeardown: '/tests/globalTeardown.js', + // globalSetup/globalTeardown ne sont pas des suites de tests + testPathIgnorePatterns: ['/node_modules/', '/tests/globalSetup.js', '/tests/globalTeardown.js'], +}; diff --git a/back/middleware/auth.middleware.js b/back/middleware/auth.middleware.js index 940cf0c..0dd7259 100644 --- a/back/middleware/auth.middleware.js +++ b/back/middleware/auth.middleware.js @@ -1,5 +1,6 @@ // On importe jsonwebtoken pour vérifier les tokens const jwt = require("jsonwebtoken"); +const config = require("../config/env"); /** * Middleware d'authentification JWT @@ -9,9 +10,16 @@ const jwt = require("jsonwebtoken"); * * Fonctionnement : * 1. Vérifie la présence d'un header Authorization contenant : "Bearer TOKEN" - * 2. Vérifie que le token est valide (non expiré, signature correcte) + * 2. Vérifie que le token est valide (signature HS256 correcte, non expiré) * 3. Si valide → attache l'utilisateur décodé à req.user et continue la route * 4. Sinon → renvoie une erreur 401 (non autorisé) + * + * Sécurité : + * - Ne JAMAIS logger les headers (le Bearer token finirait dans les logs). + * - Ne JAMAIS renvoyer le détail de l'erreur JWT au client (fuite d'infos + * sur la vérification de signature). + * - Algorithme épinglé à HS256 : empêche la confusion d'algorithme + * (ex: token forgé en "none" ou RS256). */ const authMiddleware = (req, res, next) => { try { @@ -20,28 +28,18 @@ const authMiddleware = (req, res, next) => { // Si aucun token n'est fourni → accès refusé if (!authHeader) { - console.error('Auth failed: missing Authorization header', { - path: req.originalUrl || req.url, - method: req.method, - headers: req.headers, - }); - return res.status(401).json({ message: "Accès refusé : token manquant." }); + return res.status(401).json({ success: false, message: "Accès refusé : token manquant." }); } // Le token doit être sous la forme "Bearer TOKEN" - const token = authHeader.split(" ")[1]; - - if (!token) { - console.error('Auth failed: token mal formaté', { - path: req.originalUrl || req.url, - method: req.method, - headers: req.headers, - }); - return res.status(401).json({ message: "Token invalide ou mal formaté." }); + const [scheme, token] = authHeader.split(" "); + + if (scheme !== "Bearer" || !token) { + return res.status(401).json({ success: false, message: "Token invalide ou mal formaté." }); } - // Vérification du token avec la clé secrète située dans le .env - const decoded = jwt.verify(token, process.env.JWT_SECRET); + // Vérification signature + expiration avec la clé secrète du .env + const decoded = jwt.verify(token, config.jwtSecret, { algorithms: ["HS256"] }); // Attache les infos utilisateur au req pour les utiliser dans les routes req.user = decoded; @@ -49,15 +47,10 @@ const authMiddleware = (req, res, next) => { // On continue vers le controller next(); } catch (error) { - console.error('Auth verification failed', { - message: error && error.message, - stack: error && error.stack, - path: req.originalUrl || req.url, - method: req.method, - headers: req.headers, - }); - return res.status(401).json({ message: "Token invalide ou expiré.", error: error && error.message }); + // Log minimal : type d'erreur + route, jamais les headers ni le token + console.warn(`🔒 [AUTH] ${error.name} sur ${req.method} ${req.originalUrl || req.url}`); + return res.status(401).json({ success: false, message: "Token invalide ou expiré." }); } }; -module.exports = authMiddleware; \ No newline at end of file +module.exports = authMiddleware; diff --git a/back/middleware/rateLimit.middleware.js b/back/middleware/rateLimit.middleware.js new file mode 100644 index 0000000..c2ce31e --- /dev/null +++ b/back/middleware/rateLimit.middleware.js @@ -0,0 +1,47 @@ +'use strict'; + +const { rateLimit, ipKeyGenerator } = require('express-rate-limit'); +const config = require('../config/env'); + +// Les tests Jest enchaînent des centaines de requêtes : on désactive le +// rate-limiting en environnement de test uniquement. +const isTest = config.nodeEnv === 'test'; + +const standardOptions = { + windowMs: 15 * 60 * 1000, + standardHeaders: true, // RateLimit-* headers pour les clients + legacyHeaders: false, + skip: () => isTest, + message: { + success: false, + status: 429, + message: 'Trop de requêtes. Réessayez dans quelques minutes.', + }, +}; + +// ── Limiteur global : toutes les routes /api ───────────────────────────────── +// 300 requêtes / 15 min / IP — large pour un usage normal de l'app, +// bloquant pour un scraping ou un spam de scripts. +const globalLimiter = rateLimit({ + ...standardOptions, + limit: 300, +}); + +// ── Limiteur strict : routes d'authentification ────────────────────────────── +// Clé IP + email ciblé → protège chaque compte du brute-force (login, OTP, +// reset password) même si l'attaquant tourne sur plusieurs comptes. +const authLimiter = rateLimit({ + ...standardOptions, + limit: 20, + keyGenerator: (req) => { + const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase() : ''; + return `${ipKeyGenerator(req.ip)}|${email}`; + }, + message: { + success: false, + status: 429, + message: "Trop de tentatives d'authentification. Réessayez dans 15 minutes.", + }, +}); + +module.exports = { globalLimiter, authLimiter }; diff --git a/back/middleware/sanitize.middleware.js b/back/middleware/sanitize.middleware.js new file mode 100644 index 0000000..1cc84a7 --- /dev/null +++ b/back/middleware/sanitize.middleware.js @@ -0,0 +1,37 @@ +'use strict'; + +/** + * Assainissement anti-injection NoSQL. + * + * Supprime récursivement les clés d'objet commençant par `$` ou contenant + * un `.` dans body / params / query : c'est le vecteur des injections + * d'opérateurs MongoDB (ex: { email: { $ne: null } } au login). + * + * On mutate les objets en place (sans réassigner req.query, qui est un + * getter en Express 5). Les valeurs texte ne sont pas modifiées : React + * échappe le HTML au rendu, et les schémas Joi valident les formats. + * (express-mongo-sanitize est incompatible Express 5, d'où ce middleware.) + */ +function stripDangerousKeys(obj, depth = 0) { + if (depth > 10 || obj === null || typeof obj !== 'object') return; + + for (const key of Object.keys(obj)) { + if (key.startsWith('$') || key.includes('.')) { + delete obj[key]; + } else { + stripDangerousKeys(obj[key], depth + 1); + } + } +} + +module.exports = function sanitizeMiddleware(req, _res, next) { + try { + stripDangerousKeys(req.body); + stripDangerousKeys(req.params); + stripDangerousKeys(req.query); + } catch (_) { + // Un échec d'assainissement ne doit jamais faire tomber la requête : + // Joi et Mongoose restent les gardes-fous en aval. + } + next(); +}; diff --git a/back/package-lock.json b/back/package-lock.json index 1089450..b2ef792 100644 --- a/back/package-lock.json +++ b/back/package-lock.json @@ -13,13 +13,14 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "express-validator": "^7.2.1", "helmet": "^8.0.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", "mongoose": "^9.0.0", "morgan": "^1.10.0", - "nodemailer": "^8.0.10" + "nodemailer": "^9.0.3" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -28,6 +29,7 @@ "husky": "^9.1.7", "jest": "^30.2.0", "lint-staged": "^17.0.7", + "mongodb-memory-server": "^11.2.0", "nodemon": "^3.1.11", "supertest": "^7.2.2" } @@ -1943,6 +1945,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2049,6 +2061,16 @@ "dev": true, "license": "MIT" }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2056,6 +2078,21 @@ "dev": true, "license": "MIT" }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", @@ -2165,6 +2202,104 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz", + "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -2708,6 +2843,13 @@ "node": ">= 0.8" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -3267,6 +3409,16 @@ "dev": true, "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3369,6 +3521,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", @@ -3389,6 +3559,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3467,6 +3644,40 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3505,6 +3716,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3911,6 +4143,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4018,6 +4264,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5534,6 +5789,71 @@ "node": ">=20.19.0" } }, + "node_modules/mongodb-memory-server": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.2.0.tgz", + "integrity": "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "11.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.2.0.tgz", + "integrity": "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.3", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.16.0", + "https-proxy-agent": "^7.0.6", + "mongodb": "^7.2.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.3", + "tar-stream": "^3.1.8", + "tslib": "^2.8.1", + "yauzl": "^3.3.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mongoose": { "version": "9.7.3", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.7.3.tgz", @@ -5647,6 +5967,19 @@ "node": ">= 0.6" } }, + "node_modules/new-find-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12.22.0" + } + }, "node_modules/node-addon-api": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", @@ -5685,9 +6018,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", - "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -6007,6 +6340,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6702,6 +7042,18 @@ "node": ">= 0.8" } }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -6961,6 +7313,29 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -7029,6 +7404,16 @@ "node": "*" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -7095,8 +7480,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -7601,6 +7985,19 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/back/package.json b/back/package.json index be62261..5965fb0 100644 --- a/back/package.json +++ b/back/package.json @@ -24,13 +24,14 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "express-validator": "^7.2.1", "helmet": "^8.0.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", "mongoose": "^9.0.0", "morgan": "^1.10.0", - "nodemailer": "^8.0.10" + "nodemailer": "^9.0.3" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -39,6 +40,7 @@ "husky": "^9.1.7", "jest": "^30.2.0", "lint-staged": "^17.0.7", + "mongodb-memory-server": "^11.2.0", "nodemon": "^3.1.11", "supertest": "^7.2.2" } diff --git a/back/server.js b/back/server.js index e31d764..23e4f38 100644 --- a/back/server.js +++ b/back/server.js @@ -1,22 +1,65 @@ +const mongoose = require("mongoose"); const app = require("./app"); const config = require("./config/env"); const connectDB = require("./config/db"); +// ─── Filets de sécurité process-level ──────────────────────────────────────── +// Une promesse rejetée non catchée ne doit jamais tuer le process silencieusement. +process.on("unhandledRejection", (reason) => { + console.error("🔥 [unhandledRejection]", reason); +}); + +// Une exception synchrone non catchée laisse le process dans un état incertain : +// on log puis on sort proprement — l'orchestrateur (Render, PM2…) redémarre. +process.on("uncaughtException", (error) => { + console.error("🔥 [uncaughtException]", error); + process.exit(1); +}); + +// ─── Observabilité de la connexion MongoDB ─────────────────────────────────── +// Si Atlas flanche en cours de route, Mongoose bufferise et retente tout seul ; +// les requêtes en échec remontent au error middleware (500 propre, pas de crash). +mongoose.connection.on("disconnected", () => { + console.warn("⚠️ [MongoDB] connexion perdue — reconnexion automatique en cours…"); +}); +mongoose.connection.on("reconnected", () => { + console.log("✅ [MongoDB] reconnecté."); +}); +mongoose.connection.on("error", (err) => { + console.error("❌ [MongoDB]", err.message); +}); + /** * Démarrage du serveur */ const startServer = async () => { - try { - // 1. Connexion Base de données - await connectDB(); - - // 2. Écoute du serveur - app.listen(config.port,'0.0.0.0', () => { - // server started - }); - } catch (_error) { - process.exit(1); - } + try { + // 1. Connexion Base de données + await connectDB(); + + // 2. Écoute du serveur + const server = app.listen(config.port, "0.0.0.0", () => { + console.log(`🚀 Athly API démarrée sur le port ${config.port} (${config.nodeEnv})`); + }); + + // 3. Arrêt gracieux : on refuse les nouvelles connexions, on laisse + // les requêtes en cours se terminer, puis on ferme MongoDB. + const shutdown = (signal) => { + console.log(`\n${signal} reçu — arrêt gracieux…`); + server.close(async () => { + await mongoose.connection.close(); + process.exit(0); + }); + // Garde-fou : si des connexions traînent, on force après 10 s. + setTimeout(() => process.exit(1), 10000).unref(); + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + } catch (error) { + console.error("❌ Démarrage impossible :", error.message); + process.exit(1); + } }; -startServer(); \ No newline at end of file +startServer(); diff --git a/back/tests/globalSetup.js b/back/tests/globalSetup.js new file mode 100644 index 0000000..08d4a74 --- /dev/null +++ b/back/tests/globalSetup.js @@ -0,0 +1,22 @@ +'use strict'; + +const { MongoMemoryServer } = require('mongodb-memory-server'); + +/** + * Démarre une instance MongoDB en mémoire avant toute la suite Jest. + * + * Sécurité : les tests font des deleteMany({}) — ils ne doivent JAMAIS + * s'exécuter contre une vraie base. On écrase process.env.MONGO_URI ici, + * avant que dotenv ne soit chargé (dotenv n'écrase pas une variable déjà + * définie), donc l'URI du .env local est ignorée pendant les tests. + */ +module.exports = async function globalSetup() { + const mongod = await MongoMemoryServer.create(); + + process.env.MONGO_URI = mongod.getUri('athly-test'); + process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = process.env.JWT_SECRET || 'jest-only-secret'; + + // Partagé avec globalTeardown (même process runner) + globalThis.__MONGOD__ = mongod; +}; diff --git a/back/tests/globalTeardown.js b/back/tests/globalTeardown.js new file mode 100644 index 0000000..1720744 --- /dev/null +++ b/back/tests/globalTeardown.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = async function globalTeardown() { + if (globalThis.__MONGOD__) { + await globalThis.__MONGOD__.stop(); + } +}; diff --git a/back/tests/inventory.test.js b/back/tests/inventory.test.js index 33be954..78c50bd 100644 --- a/back/tests/inventory.test.js +++ b/back/tests/inventory.test.js @@ -120,6 +120,33 @@ describe("Système Inventaire & Coffres Athly — V2", () => { const res = await request(app).post('/api/inventory/chest/open'); expect(res.statusCode).toBe(401); }); + + it('🔒 Anti double-spend : 5 ouvertures simultanées avec 1 seule clé → 1 seul succès', async () => { + await User.updateOne( + { _id: user.userId }, + { level: 11, inventory: [{ itemType: 'CHEST_KEY', rarity: 'common', quantity: 1 }] }, + ); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + request(app) + .post('/api/inventory/chest/open') + .set('Authorization', `Bearer ${user.token}`), + ), + ); + + const successes = results.filter((r) => r.statusCode === 200); + const rejected = results.filter((r) => r.statusCode === 400); + expect(successes).toHaveLength(1); + expect(rejected).toHaveLength(4); + + // La clé est bien consommée une seule fois, un seul item a été crédité + const updatedUser = await User.findById(user.userId); + const chestKey = updatedUser.inventory.find((i) => i.itemType === 'CHEST_KEY'); + expect(chestKey).toBeUndefined(); + const totalItems = updatedUser.inventory.reduce((sum, i) => sum + i.quantity, 0); + expect(totalItems).toBe(1); + }); }); // ─────────────────────────────────────────────────────────────────────────── diff --git a/docs/ARCHITECTURE-SECURITE.md b/docs/ARCHITECTURE-SECURITE.md new file mode 100644 index 0000000..8a595fc --- /dev/null +++ b/docs/ARCHITECTURE-SECURITE.md @@ -0,0 +1,75 @@ +# Athly — Architecture de sécurité & résilience + +Référence des protections en place sur la stack (Node.js/Express + PWA Expo Web). +Chaque section pointe vers le fichier source de vérité. + +--- + +## 1. Sécurité API (backend) + +| Protection | Implémentation | Fichier | +|---|---|---| +| Headers HTTP durcis | `helmet()` (CSP, HSTS, noSniff, frameguard…) | `back/app.js` | +| CORS restreint | Allowlist via `CORS_ORIGINS` (csv). Sans Origin (app native) → non concerné. Non définie → permissif + warning en prod | `back/app.js` | +| Rate-limiting global | 300 req / 15 min / IP sur `/api` | `back/middleware/rateLimit.middleware.js` | +| Anti brute-force auth | 20 req / 15 min, clé **IP + email ciblé** sur `/api/auth` (login, OTP, reset) | idem | +| Injection NoSQL | Strip récursif des clés `$…` et `a.b` dans body/params/query (express-mongo-sanitize est incompatible Express 5) | `back/middleware/sanitize.middleware.js` | +| Validation entrantes | Schémas Joi sur toutes les routes à body (rejet des champs inconnus par défaut) | `back/validators/*` | +| Payload max | `express.json({ limit: '1mb' })` | `back/app.js` | +| JWT imperméable | Algorithme épinglé HS256, signature+expiration vérifiées, **aucun log de headers/token**, erreur générique côté client | `back/middleware/auth.middleware.js` | +| Cache intermédiaires | `Cache-Control: no-store` sur toutes les réponses `/api` | `back/app.js` | + +Notes de déploiement : +- `app.set('trust proxy', 1)` est requis derrière Render pour que `req.ip` soit la vraie IP client. +- **En production, définir `CORS_ORIGINS`** avec le(s) domaine(s) de la PWA. +- Le rate-limiting est désactivé quand `NODE_ENV=test` (les suites Jest enchaînent des centaines de requêtes). + +## 2. Tolérance aux pannes & concurrence + +- **Process-level** (`back/server.js`) : `unhandledRejection` logué sans tuer le process ; + `uncaughtException` → log + exit propre (l'orchestrateur redémarre) ; arrêt gracieux + SIGTERM/SIGINT (drain des requêtes en cours, fermeture MongoDB, garde-fou 10 s). +- **MongoDB flanche** : Mongoose reconnecte automatiquement (events logués) ; les requêtes + en échec remontent au error middleware → 500 JSON standardisé, jamais de crash. +- **Race conditions inventaire** (`back/controllers/inventory.controller.js`) : + consommation d'items **atomique** (`findOneAndUpdate` conditionnel + `$inc`). + Deux requêtes simultanées sur la dernière `CHEST_KEY` → une seule réussit. + Test de régression : « 5 ouvertures simultanées → 1 seul succès » (`back/tests/inventory.test.js`). +- **Erreurs de rendu front** : `ErrorBoundary` global (`front/src/components/common/ErrorBoundary.js`) + monté dans `App.js` — état dégradé + bouton recharger, plus d'écran blanc. + +## 3. PWA — offline & performance + +- **Service worker** (`front/public/sw.js`, cache `athly-shell-v2`) : + navigations en *network-first* avec fallback shell hors-ligne ; assets statiques en + *stale-while-revalidate* (chargement instantané) ; `/api/` jamais caché par le SW. +- **Mode dégradé réseau** (`front/src/api/api.js`) : chaque GET réussi est mis en cache + AsyncStorage (`athly:apicache:v1:*`) ; si le réseau tombe, les GET sont servis depuis + ce cache avec `fromCache: true` au lieu d'échouer. **Purgé à la déconnexion** + (`AuthContext.signOut` → `purgeApiCache`) : rien ne survit sur un appareil partagé. +- **Fuites mémoire** : tous les `setInterval`/listeners du code ont un cleanup vérifié + (audit 2026-07). Règle : tout `setInterval` dans un effet doit retourner son `clearInterval`. + +## 4. CI/CD (`.github/workflows/ci.yml`) + +Pipeline sur chaque push/PR vers `main`/`develop` (jobs conditionnés aux chemins modifiés) : + +1. **Backend** : ESLint → syntax check → `npm audit --omit=dev --audit-level=high` + (bloquant) → **199 tests** Jest/Supertest sur **MongoDB en mémoire** + (`mongodb-memory-server` via `back/tests/globalSetup.js`) — plus aucun besoin de + service Mongo ni de `MONGO_URI` : l'environnement de test est 100 % hermétique, + identique en CI et en local. `npm test` local est donc **sans danger** (la base + Atlas du `.env` est ignorée pendant les tests). +2. **Frontend** : install → `npm audit` (bloquant) → build PWA complet (`expo export`). + +**Blocage du merge** : à activer une fois dans GitHub (Settings → Branches → Branch +protection rules sur `main` et `develop` → *Require status checks to pass* en cochant +les jobs `Backend — Lint, Audit & Tests` et `Frontend — PWA Build`), ou via : + +```bash +gh api repos/{owner}/{repo}/branches/develop/protection -X PUT \ + -f "required_status_checks[strict]=true" \ + -f "required_status_checks[contexts][]=Backend — Lint, Audit & Tests" \ + -f "required_status_checks[contexts][]=Frontend — PWA Build" \ + -F "enforce_admins=false" -F "required_pull_request_reviews=null" -F "restrictions=null" +``` diff --git a/front/App.js b/front/App.js index 6c2dd4d..efc7cbb 100644 --- a/front/App.js +++ b/front/App.js @@ -6,6 +6,7 @@ import { ToastProvider } from './src/context/ToastContext'; import AppNavigator from './src/navigation'; import DesktopInstallPage from './src/components/web/DesktopInstallPage'; +import ErrorBoundary from './src/components/common/ErrorBoundary'; const isDesktopWeb = Platform.OS === 'web' && @@ -18,13 +19,15 @@ export default function App() { } return ( - - - - - - - - + + + + + + + + + + ); } diff --git a/front/public/sw.js b/front/public/sw.js index c965b1e..6263eb9 100644 --- a/front/public/sw.js +++ b/front/public/sw.js @@ -1,5 +1,11 @@ -// Service Worker Athly — stratégie stale-while-revalidate -const CACHE_NAME = 'athly-shell-v1'; +// Service Worker Athly +// Stratégies : +// - Navigations (HTML) → network-first : contenu frais en ligne, +// fallback sur le shell en cache hors-ligne (l'app charge toujours). +// - Assets statiques → stale-while-revalidate : chargement instantané +// depuis le cache, rafraîchi en arrière-plan. +// - /api/ → jamais caché ici (cache dégradé géré côté app). +const CACHE_NAME = 'athly-shell-v2'; const SHELL_ASSETS = ['/', '/index.html']; // Installation : mise en cache du shell applicatif @@ -20,15 +26,35 @@ self.addEventListener('activate', (event) => { self.clients.claim(); }); -// Fetch : stale-while-revalidate pour les assets statiques self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); - // On ne cache pas : non-GET, cross-origin, ou appels API backend + // On ne gère pas : non-GET, cross-origin, ou appels API backend if (event.request.method !== 'GET') return; if (url.origin !== location.origin) return; if (url.pathname.startsWith('/api/')) return; + // ── Navigations : network-first, fallback shell en cache ─────────────────── + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request) + .then((response) => { + if (response && response.status === 200) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put('/', clone)); + } + return response; + }) + .catch(() => + caches.match('/').then( + (cached) => cached || new Response('Hors ligne', { status: 503 }) + ) + ) + ); + return; + } + + // ── Assets statiques : stale-while-revalidate ─────────────────────────────── event.respondWith( caches.open(CACHE_NAME).then((cache) => cache.match(event.request).then((cached) => { diff --git a/front/src/api/api.js b/front/src/api/api.js index 84dfb5f..d412db8 100644 --- a/front/src/api/api.js +++ b/front/src/api/api.js @@ -1,7 +1,41 @@ import axios from 'axios'; import { API_URL } from '@env'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { getToken, removeToken } from '../utils/authStorage'; +// ── Cache réseau dégradé ────────────────────────────────────────────────────── +// Chaque réponse GET réussie est mise en cache. Si le réseau tombe (serveur +// down, offline, cold-start Render), les GET sont servis depuis ce cache avec +// `fromCache: true` au lieu d'échouer → l'UI affiche un état "stale" au lieu +// d'un écran d'erreur. Le cache est purgé à la déconnexion (purgeApiCache). +const API_CACHE_PREFIX = 'athly:apicache:v1:'; + +async function readApiCache(url) { + try { + const raw = await AsyncStorage.getItem(API_CACHE_PREFIX + url); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function writeApiCache(url, data) { + // Fire-and-forget : un échec d'écriture du cache ne doit rien bloquer + AsyncStorage.setItem(API_CACHE_PREFIX + url, JSON.stringify(data)).catch(() => {}); +} + +// À appeler à la déconnexion : aucune donnée utilisateur ne doit survivre +// dans le cache sur un appareil partagé. +export async function purgeApiCache() { + try { + const keys = await AsyncStorage.getAllKeys(); + const mine = keys.filter((k) => k.startsWith(API_CACHE_PREFIX)); + if (mine.length > 0) await AsyncStorage.multiRemove(mine); + } catch { + // best effort + } +} + // Référence vers la fonction signOut de AuthContext, injectée au montage du provider. // Permet à l'intercepteur (code hors-React) de déclencher la déconnexion proprement. let _signOutCallback = null; @@ -62,7 +96,13 @@ API.interceptors.request.use( // Intercepteur de réponse — gestion centralisée des erreurs + déconnexion JWT API.interceptors.response.use( - (response) => response, + (response) => { + // Alimente le cache dégradé avec les GET réussis + if (response.config?.method === 'get' && response.config?.url) { + writeApiCache(response.config.url, response.data); + } + return response; + }, async (error) => { const err = { isAxiosError: error.isAxiosError || false, @@ -99,6 +139,21 @@ API.interceptors.response.use( if (error.request) { err.network = true; err.message = "Aucune réponse du serveur. Vérifiez votre connexion réseau."; + + // ── Mode dégradé : GET en échec réseau → réponse servie depuis le cache ── + const cfg = error.config; + if (cfg?.method === 'get' && cfg?.url) { + const cached = await readApiCache(cfg.url); + if (cached !== null) { + return Promise.resolve({ + data: cached, + status: 200, + fromCache: true, + config: cfg, + }); + } + } + return Promise.reject(err); } diff --git a/front/src/components/common/ErrorBoundary.js b/front/src/components/common/ErrorBoundary.js new file mode 100644 index 0000000..482a291 --- /dev/null +++ b/front/src/components/common/ErrorBoundary.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native'; +import { Colors } from '../../constants/theme'; + +// ─── ErrorBoundary ──────────────────────────────────────────────────────────── +// Filet de sécurité global : une erreur de rendu dans n'importe quel écran +// affiche cet état dégradé au lieu d'un écran blanc irrécupérable. +// Doit rester un class component : les hooks ne peuvent pas capturer les +// erreurs de rendu (componentDidCatch n'a pas d'équivalent hook). + +export default class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error, info) { + // Log console uniquement — brancher un service de crash-reporting ici + // (Sentry…) le jour où on en ajoute un. + console.error('💥 [ErrorBoundary]', error, info?.componentStack); + } + + handleRetry = () => { + // Sur web (PWA), un vrai reload repart du service worker (état sain garanti). + // Sur natif, on retente simplement le rendu de l'arbre. + if (Platform.OS === 'web' && typeof window !== 'undefined') { + window.location.reload(); + return; + } + this.setState({ hasError: false }); + }; + + render() { + if (!this.state.hasError) return this.props.children; + + return ( + + 🏋️ + Oups, une erreur est survenue + + Pas de panique — tes données sont en sécurité.{'\n'}Réessaie, ça devrait repartir. + + + Recharger l'application + + + ); + } +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: Colors.bgAbyss, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, + }, + emoji: { fontSize: 48, marginBottom: 18 }, + title: { + color: Colors.textPrimary, + fontSize: 19, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 10, + textAlign: 'center', + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 28, + }, + btn: { + height: 50, + paddingHorizontal: 28, + borderRadius: 13, + backgroundColor: Colors.primary, + justifyContent: 'center', + alignItems: 'center', + }, + btnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/context/AuthContext.js b/front/src/context/AuthContext.js index fb59a77..9bf85c8 100644 --- a/front/src/context/AuthContext.js +++ b/front/src/context/AuthContext.js @@ -1,6 +1,6 @@ import React, { createContext, useState, useContext, useEffect } from 'react'; import { getToken, removeToken, saveToken, setSessionOnly, getSessionOnly } from '../utils/authStorage'; -import { setSignOutCallback } from '../api/api'; +import { setSignOutCallback, purgeApiCache } from '../api/api'; const AuthContext = createContext(); @@ -37,6 +37,8 @@ export const AuthProvider = ({ children }) => { const signOut = async () => { await removeToken(); await setSessionOnly(false); + // Aucune donnée du compte ne doit survivre dans le cache API (appareil partagé) + await purgeApiCache(); setUserToken(null); }; From 62c033d2c3b157c1fe850a51a24ee0d60061784f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 13:09:28 +0200 Subject: [PATCH 06/31] feat: integration complete MMO-RPG et reseau social - inventaire, ouverture de coffre animee, gestion d'amis, classement et streaks de group --- back/controllers/friend.controller.js | 159 ++++ back/controllers/groupStreak.controller.js | 57 +- back/controllers/inventory.controller.js | 69 +- back/models/User.js | 1 + back/routes/friend.routes.js | 7 +- back/services/inventory.service.js | 87 +++ back/services/workout.service.js | 48 ++ back/tests/socialEngine.test.js | 391 ++++++++++ .../components/inventory/ChestOpeningModal.js | 195 +++++ front/src/navigation/BottomTabs.js | 13 +- front/src/navigation/ProfileStack.js | 6 + front/src/navigation/SocialStack.js | 16 + front/src/navigation/index.js | 1 + front/src/screens/Profile/InventoryScreen.js | 326 +++++++++ front/src/screens/Profile/ProfileScreen.js | 6 + .../src/screens/Social/FriendProfileScreen.js | 169 +++++ front/src/screens/Social/SocialScreen.js | 689 ++++++++++++++++++ front/src/services/inventory.service.js | 63 ++ front/src/services/social.service.js | 77 ++ 19 files changed, 2306 insertions(+), 74 deletions(-) create mode 100644 back/services/inventory.service.js create mode 100644 back/tests/socialEngine.test.js create mode 100644 front/src/components/inventory/ChestOpeningModal.js create mode 100644 front/src/navigation/SocialStack.js create mode 100644 front/src/screens/Profile/InventoryScreen.js create mode 100644 front/src/screens/Social/FriendProfileScreen.js create mode 100644 front/src/screens/Social/SocialScreen.js create mode 100644 front/src/services/inventory.service.js create mode 100644 front/src/services/social.service.js diff --git a/back/controllers/friend.controller.js b/back/controllers/friend.controller.js index 5bf3695..be80575 100644 --- a/back/controllers/friend.controller.js +++ b/back/controllers/friend.controller.js @@ -257,3 +257,162 @@ exports.getPendingRequests = async (req, res, next) => { next(err); } }; + +// ───────────────────────────────────────────────────────────────────────────── +// searchUsers GET /api/friends/search?q= +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Recherche d'utilisateurs par pseudo (insensible à la casse). + * Chaque résultat est annoté du statut de relation avec l'utilisateur + * connecté : none | pending_sent | pending_received | accepted. + * La regex est échappée : aucune injection de pattern possible. + */ +exports.searchUsers = async (req, res, next) => { + try { + const myId = req.user.id; + const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''; + + if (q.length < 2) { + return next(createError('La recherche doit contenir au moins 2 caractères.', 400)); + } + + const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const users = await User.find({ + _id: { $ne: myId }, + pseudo: { $regex: escaped, $options: 'i' }, + }) + .select(FRIEND_PUBLIC_FIELDS) + .limit(20); + + // Annotation du statut de relation en une seule requête + const ids = users.map((u) => u._id); + const relations = await Friendship.find({ + $or: [ + { requester: myId, recipient: { $in: ids } }, + { recipient: myId, requester: { $in: ids } }, + ], + }); + + const results = users.map((u) => { + const rel = relations.find( + (r) => r.requester.toString() === u._id.toString() || r.recipient.toString() === u._id.toString(), + ); + let relationStatus = 'none'; + let requestId = null; + if (rel) { + requestId = rel._id; + if (rel.status === 'accepted') relationStatus = 'accepted'; + else if (rel.status === 'pending') { + relationStatus = rel.requester.toString() === myId ? 'pending_sent' : 'pending_received'; + } + } + return { user: u, relationStatus, requestId }; + }); + + return res.status(200).json({ success: true, count: results.length, results }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// getFriendProfile GET /api/friends/profile/:friendId +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Profil public d'un AMI ACCEPTÉ uniquement : identité de jeu, progression, + * trophées débloqués, records d'exercices (top 5 par poids), niveau d'amitié. + * 403 si la relation n'est pas acceptée — pas de fuite de données entre inconnus. + */ +exports.getFriendProfile = async (req, res, next) => { + try { + const myId = req.user.id; + const { friendId } = req.params; + + if (!isValidId(friendId)) return next(createError('friendId invalide.', 400)); + + const friendship = await Friendship.findOne({ + $or: [ + { requester: myId, recipient: friendId }, + { requester: friendId, recipient: myId }, + ], + status: 'accepted', + }); + if (!friendship) { + return next(createError("Vous devez être amis pour consulter ce profil.", 403)); + } + + const friend = await User.findById(friendId) + .select('pseudo level rank xp achievements streakGels totalWorkoutMinutes createdAt'); + if (!friend) return next(createError('Utilisateur introuvable.', 404)); + + // Top 5 des records par poids max soulevé + const ExerciseRecord = require('../models/ExerciseRecord'); + const records = await ExerciseRecord.aggregate([ + { $match: { user: new mongoose.Types.ObjectId(friendId) } }, + { $unwind: '$series' }, + { $group: { + _id: '$exerciceNom', + maxPoids: { $max: '$series.poids' }, + maxReps: { $max: '$series.repetitions' }, + } }, + { $sort: { maxPoids: -1 } }, + { $limit: 5 }, + ]); + + return res.status(200).json({ + success: true, + profile: { + user: friend, + friendshipLevel: friendship.friendshipLevel, + friendshipXp: friendship.friendshipXp, + achievementsCount: friend.achievements.length, + records: records.map((r) => ({ + exercice: r._id, + maxPoids: r.maxPoids, + maxReps: r.maxReps, + })), + }, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// getLeaderboard GET /api/friends/leaderboard +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Classement dynamique : l'utilisateur + tous ses amis acceptés, + * triés par XP décroissant, avec le rang de chacun. + */ +exports.getLeaderboard = async (req, res, next) => { + try { + const myId = req.user.id; + + const friendships = await Friendship.find({ + $or: [{ requester: myId }, { recipient: myId }], + status: 'accepted', + }); + + const friendIds = friendships.map((f) => + f.requester.toString() === myId ? f.recipient : f.requester, + ); + + const competitors = await User.find({ _id: { $in: [...friendIds, myId] } }) + .select(FRIEND_PUBLIC_FIELDS) + .sort({ xp: -1 }); + + const leaderboard = competitors.map((u, index) => ({ + position: index + 1, + user: u, + isMe: u._id.toString() === myId, + })); + + return res.status(200).json({ success: true, count: leaderboard.length, leaderboard }); + } catch (err) { + next(err); + } +}; diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index 29ea758..8e55177 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -5,6 +5,9 @@ const StreakGroup = require('../models/StreakGroup'); const Friendship = require('../models/Friendship'); const User = require('../models/User'); const Workout = require('../models/Workout'); +const { addUniqueItemOnce } = require('../services/inventory.service'); +const { checkAndUnlockAchievements } = require('./reward.controller'); +const { levelFromXP } = require('../utils/levelHelpers'); // ─── Constantes ─────────────────────────────────────────────────────────────── @@ -58,6 +61,10 @@ function computeFriendshipLevel(xp) { * Ajoute `xpGain` au document Friendship entre userId1 et userId2 (accepted). * Met à jour le niveau d'amitié si un seuil est franchi. * Si aucune amitié acceptée n'existe, ignore silencieusement. + * + * Passage au niveau 5 (Rareté Unique) : injecte le cadre cosmétique + * PROFILE_FRAME_BLOOD_BOND (hors coffres, une seule fois) dans l'inventaire + * des deux amis et déclenche le déblocage du trophée FRIENDSHIP_LEVEL_5. */ async function addFriendshipXp(userId1, userId2, xpGain) { const friendship = await Friendship.findOne({ @@ -69,9 +76,45 @@ async function addFriendshipXp(userId1, userId2, xpGain) { }); if (!friendship) return; + const previousLevel = friendship.friendshipLevel; friendship.friendshipXp += xpGain; friendship.friendshipLevel = computeFriendshipLevel(friendship.friendshipXp); await friendship.save(); + + if (previousLevel < 5 && friendship.friendshipLevel === 5) { + await Promise.all([ + addUniqueItemOnce(userId1, 'PROFILE_FRAME_BLOOD_BOND', 'unique'), + addUniqueItemOnce(userId2, 'PROFILE_FRAME_BLOOD_BOND', 'unique'), + ]); + await Promise.all([ + checkAndUnlockAchievements(String(userId1)), + checkAndUnlockAchievements(String(userId2)), + ]); + } +} + +// ── Bonus XP de groupe (Brique IV) ──────────────────────────────────────────── +// Multiplicateur croissant avec la taille du groupe : x1.25 par membre +// au-delà du premier (2 → x1.25, 5 → x2.0), appliqué à un bonus de base. +const GROUP_BASE_BONUS_XP = 40; + +function computeGroupXpBonus(memberCount) { + const multiplier = 1 + 0.25 * (memberCount - 1); + return { multiplier, bonusXp: Math.round(GROUP_BASE_BONUS_XP * multiplier) }; +} + +/** Crédite atomiquement le bonus XP à un membre et recale son niveau/rang. */ +async function grantGroupXpBonus(memberId, bonusXp) { + const updated = await User.findOneAndUpdate( + { _id: memberId }, + { $inc: { xp: bonusXp } }, + { returnDocument: 'after' }, + ); + if (!updated) return; + const newLevel = levelFromXP(updated.xp); + if (newLevel !== updated.level) { + await User.updateOne({ _id: memberId }, { $set: { level: newLevel } }); + } } // ───────────────────────────────────────────────────────────────────────────── @@ -394,6 +437,10 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { } } + // Bonus XP utilisateur : multiplicateur croissant avec la taille du groupe + const { multiplier, bonusXp } = computeGroupXpBonus(memberIds.length); + await Promise.all(memberIds.map((id) => grantGroupXpBonus(id, bonusXp))); + return res.status(200).json({ success: true, allValidated: true, @@ -401,6 +448,7 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { currentStreak: group.currentStreak, xpGain, xpUpdates, + groupBonus: { multiplier, bonusXp, memberCount: memberIds.length }, }); } catch (err) { next(err); @@ -424,15 +472,22 @@ exports.getMyGroup = async (req, res, next) => { .populate('members', MEMBER_PUBLIC_FIELDS) .populate('pendingInvites', 'pseudo level rank'); + // Invitations de groupe reçues (groupes où je suis en pendingInvites), + // pour que l'invité puisse accepter/refuser depuis l'app. + const invites = await StreakGroup.find({ pendingInvites: myId }) + .populate('members', MEMBER_PUBLIC_FIELDS) + .select('name currentStreak members'); + if (!group) { return res.status(200).json({ success: true, group: null, + invites, message: "Vous ne faites partie d'aucun groupe.", }); } - return res.status(200).json({ success: true, group }); + return res.status(200).json({ success: true, group, invites }); } catch (err) { next(err); } diff --git a/back/controllers/inventory.controller.js b/back/controllers/inventory.controller.js index b51a81a..e9c3cf4 100644 --- a/back/controllers/inventory.controller.js +++ b/back/controllers/inventory.controller.js @@ -2,6 +2,7 @@ const User = require('../models/User'); const { drawChestItem } = require('../services/chest.service'); +const { consumeItemAtomic, addItemAtomic, purgeEmptyEntries } = require('../services/inventory.service'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -32,74 +33,6 @@ function getRankForLevel(level) { return match ? match.rank : 'Novice'; } -/** - * Consomme atomiquement 1 unité d'un item. - * - * Anti race-condition (double-spend) : le filtre conditionnel garantit que le - * décrément n'a lieu que si la quantité est encore >= 1 AU MOMENT de l'écriture. - * Deux requêtes simultanées sur la dernière unité : une seule matche le filtre, - * l'autre reçoit null — impossible de dépenser deux fois le même objet. - * - * @returns Le document User APRÈS décrément, ou null si non possédé - * (ou si extraFilter ne matche pas). - */ -async function consumeItemAtomic(userId, itemType, extraFilter = {}) { - return User.findOneAndUpdate( - { - _id: userId, - inventory: { $elemMatch: { itemType, quantity: { $gte: 1 } } }, - ...extraFilter, - }, - { $inc: { 'inventory.$[elem].quantity': -1 } }, - { - returnDocument: 'after', - arrayFilters: [{ 'elem.itemType': itemType }], - }, - ); -} - -/** - * Ajoute atomiquement 1 unité d'un item ($inc si l'entrée existe, sinon $push - * gardé par $ne pour éviter un double-push concurrent). - * @returns Le document User APRÈS ajout. - */ -async function addItemAtomic(userId, itemType, rarity) { - const incremented = await User.findOneAndUpdate( - { _id: userId, 'inventory.itemType': itemType }, - { $inc: { 'inventory.$.quantity': 1 } }, - { returnDocument: 'after' }, - ); - if (incremented) return incremented; - - const pushed = await User.findOneAndUpdate( - { _id: userId, 'inventory.itemType': { $ne: itemType } }, - { $push: { inventory: { itemType, rarity, quantity: 1 } } }, - { returnDocument: 'after' }, - ); - if (pushed) return pushed; - - // Course perdue contre un $push concurrent du même itemType : on retombe - // sur le $inc, qui matche forcément maintenant. - return User.findOneAndUpdate( - { _id: userId, 'inventory.itemType': itemType }, - { $inc: { 'inventory.$.quantity': 1 } }, - { returnDocument: 'after' }, - ); -} - -/** - * Purge les entrées d'inventaire tombées à 0 (comportement historique : - * une entrée épuisée disparaît de l'inventaire). - * @returns Le document User APRÈS purge. - */ -async function purgeEmptyEntries(userId) { - return User.findOneAndUpdate( - { _id: userId }, - { $pull: { inventory: { quantity: { $lte: 0 } } } }, - { returnDocument: 'after' }, - ); -} - // Effets des consommables — chaque fonction modifie user en place // Note : DOUBLE/TRIPLE/QUINTUPLE_XP donnent un XP instantané. // Un système de boost temporaire (multiplicateur) est prévu dans une brique future. diff --git a/back/models/User.js b/back/models/User.js index e430573..7b8922e 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -25,6 +25,7 @@ const InventoryItemSchema = new mongoose.Schema( "QUINTUPLE_XP", // Boost Quintuple XP "LEVEL_COUPON", // Coupon de niveau : +1 level "CHEST_KEY", // Clé de coffre : ouvre un coffre + "PROFILE_FRAME_BLOOD_BOND", // Cadre cosmétique Unique — niveau d'amitié 5, hors coffres ], }, rarity: { diff --git a/back/routes/friend.routes.js b/back/routes/friend.routes.js index ac6cd0a..a899f52 100644 --- a/back/routes/friend.routes.js +++ b/back/routes/friend.routes.js @@ -14,7 +14,10 @@ router.put('/accept/:requestId', friend.acceptFriendRequest); // Accepter router.put('/decline/:requestId', friend.declineFriendRequest); // Refuser // ── Consultation ────────────────────────────────────────────────────────────── -router.get('/list', friend.getFriendsList); // Tous mes amis (accepted) -router.get('/pending', friend.getPendingRequests); // Demandes reçues en attente +router.get('/list', friend.getFriendsList); // Tous mes amis (accepted) +router.get('/pending', friend.getPendingRequests); // Demandes reçues en attente +router.get('/search', friend.searchUsers); // Recherche par pseudo +router.get('/leaderboard', friend.getLeaderboard); // Classement XP amis +router.get('/profile/:friendId', friend.getFriendProfile); // Profil public d'un ami module.exports = router; diff --git a/back/services/inventory.service.js b/back/services/inventory.service.js new file mode 100644 index 0000000..b6e0f3d --- /dev/null +++ b/back/services/inventory.service.js @@ -0,0 +1,87 @@ +'use strict'; + +const User = require('../models/User'); + +// ─── Opérations d'inventaire atomiques ──────────────────────────────────────── +// Toutes les écritures passent par findOneAndUpdate conditionnel : aucune +// fenêtre de race condition (double-spend, double-octroi) possible. + +/** + * Consomme atomiquement 1 unité d'un item. + * Le filtre garantit que le décrément n'a lieu que si la quantité est encore + * >= 1 au moment de l'écriture. Deux requêtes simultanées sur la dernière + * unité : une seule matche, l'autre reçoit null. + * + * @returns Le document User APRÈS décrément, ou null si non possédé + * (ou si extraFilter ne matche pas). + */ +async function consumeItemAtomic(userId, itemType, extraFilter = {}) { + return User.findOneAndUpdate( + { + _id: userId, + inventory: { $elemMatch: { itemType, quantity: { $gte: 1 } } }, + ...extraFilter, + }, + { $inc: { 'inventory.$[elem].quantity': -1 } }, + { + returnDocument: 'after', + arrayFilters: [{ 'elem.itemType': itemType }], + }, + ); +} + +/** + * Ajoute atomiquement `quantity` unités d'un item ($inc si l'entrée existe, + * sinon $push gardé par $ne pour éviter un double-push concurrent). + * @returns Le document User APRÈS ajout. + */ +async function addItemAtomic(userId, itemType, rarity, quantity = 1) { + const incremented = await User.findOneAndUpdate( + { _id: userId, 'inventory.itemType': itemType }, + { $inc: { 'inventory.$.quantity': quantity } }, + { returnDocument: 'after' }, + ); + if (incremented) return incremented; + + const pushed = await User.findOneAndUpdate( + { _id: userId, 'inventory.itemType': { $ne: itemType } }, + { $push: { inventory: { itemType, rarity, quantity } } }, + { returnDocument: 'after' }, + ); + if (pushed) return pushed; + + // Course perdue contre un $push concurrent du même itemType : on retombe + // sur le $inc, qui matche forcément maintenant. + return User.findOneAndUpdate( + { _id: userId, 'inventory.itemType': itemType }, + { $inc: { 'inventory.$.quantity': quantity } }, + { returnDocument: 'after' }, + ); +} + +/** + * Ajoute un item UNIQUEMENT s'il n'est pas déjà possédé (jamais de doublon). + * Utilisé pour les récompenses uniques (cosmétiques niveau 5 d'amitié). + * @returns Le document User si l'item vient d'être ajouté, null s'il existait déjà. + */ +async function addUniqueItemOnce(userId, itemType, rarity) { + return User.findOneAndUpdate( + { _id: userId, 'inventory.itemType': { $ne: itemType } }, + { $push: { inventory: { itemType, rarity, quantity: 1 } } }, + { returnDocument: 'after' }, + ); +} + +/** + * Purge les entrées d'inventaire tombées à 0 (une entrée épuisée disparaît). + * @returns Le document User APRÈS purge. + */ +async function purgeEmptyEntries(userId) { + return User.findOneAndUpdate( + { _id: userId }, + { $pull: { inventory: { quantity: { $lte: 0 } } } }, + { returnDocument: 'after' }, + ); +} + +module.exports = { consumeItemAtomic, addItemAtomic, addUniqueItemOnce, purgeEmptyEntries }; diff --git a/back/services/workout.service.js b/back/services/workout.service.js index badc26b..01d61f3 100644 --- a/back/services/workout.service.js +++ b/back/services/workout.service.js @@ -1,6 +1,44 @@ const Workout = require("../models/Workout"); const User = require("../models/User"); const { levelFromXP } = require("../utils/levelHelpers"); +const { addItemAtomic } = require("./inventory.service"); + +// ── Coffres à l'effort (Brique II) ─────────────────────────────────────────── +// 1 coffre (CHEST_KEY) tous les CHEST_MINUTES_THRESHOLD minutes de séance +// légitime cumulées. Le drop est verrouillé sous le niveau 11 (Rang Initié), +// comme l'ouverture des coffres. +const CHEST_MINUTES_THRESHOLD = 300; +const MIN_LEVEL_FOR_CHEST_DROP = 11; + +/** + * Accumule atomiquement les minutes de séance et attribue les CHEST_KEY + * de chaque palier de CHEST_MINUTES_THRESHOLD franchi. + * Le $inc atomique garantit qu'aucun palier n'est compté deux fois même si + * deux finalisations arrivent en même temps. + */ +async function accrueMinutesAndAwardChests(userId, minutes) { + if (!minutes || minutes <= 0) return { chestsAwarded: 0, totalWorkoutMinutes: null }; + + const updated = await User.findOneAndUpdate( + { _id: userId }, + { $inc: { totalWorkoutMinutes: minutes } }, + { returnDocument: "after" }, + ); + if (!updated) return { chestsAwarded: 0, totalWorkoutMinutes: null }; + + const total = updated.totalWorkoutMinutes; + const before = total - minutes; + const crossed = + Math.floor(total / CHEST_MINUTES_THRESHOLD) - + Math.floor(before / CHEST_MINUTES_THRESHOLD); + + if (crossed <= 0 || updated.level < MIN_LEVEL_FOR_CHEST_DROP) { + return { chestsAwarded: 0, totalWorkoutMinutes: total }; + } + + await addItemAtomic(userId, "CHEST_KEY", "common", crossed); + return { chestsAwarded: crossed, totalWorkoutMinutes: total }; +} /** * Service gérant la création et la gestion des programmes/séances. @@ -156,6 +194,14 @@ class WorkoutService { await user.save(); } + // ── Coffres à l'effort ─────────────────────────────────────────────────── + // Seules les séances légitimes (non short-session, durée >= 300 s) + // alimentent le compteur de minutes. + let chestInfo = { chestsAwarded: 0, totalWorkoutMinutes: null }; + if (user && options.shortSession !== true && duration >= 300) { + chestInfo = await accrueMinutesAndAwardChests(userId, Math.floor(duration / 60)); + } + return { workout, stats: { @@ -163,6 +209,8 @@ class WorkoutService { xp, userXP: user ? user.xp : null, userLevel: user ? user.level : null, + chestsAwarded: chestInfo.chestsAwarded, + totalWorkoutMinutes: chestInfo.totalWorkoutMinutes, }, }; } diff --git a/back/tests/socialEngine.test.js b/back/tests/socialEngine.test.js new file mode 100644 index 0000000..9e6a9b8 --- /dev/null +++ b/back/tests/socialEngine.test.js @@ -0,0 +1,391 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const Friendship = require('../models/Friendship'); +const Workout = require('../models/Workout'); +const StreakGroup = require('../models/StreakGroup'); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +async function createAndLoginUser(pseudo, email) { + await request(app) + .post('/api/auth/register') + .send({ pseudo, email, password: 'Password123!' }); + + await User.updateOne({ email }, { isVerified: true }); + + const loginRes = await request(app) + .post('/api/auth/login') + .send({ email, password: 'Password123!' }); + + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +async function makeFriends(a, b) { + return Friendship.create({ requester: a, recipient: b, status: 'accepted' }); +} + +async function finishedWorkoutToday(userId) { + return Workout.create({ + user: userId, + name: 'Séance test', + status: 'finished', + date: new Date(), + durationSeconds: 3600, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Suite : Moteur MMO-RPG social — Briques II / III / IV +// ───────────────────────────────────────────────────────────────────────────── + +describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { + let alice, bob, carol; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await Promise.all([ + User.deleteMany({}), Friendship.deleteMany({}), + Workout.deleteMany({}), StreakGroup.deleteMany({}), + ]); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await Promise.all([ + User.deleteMany({}), Friendship.deleteMany({}), + Workout.deleteMany({}), StreakGroup.deleteMany({}), + ]); + alice = await createAndLoginUser('AliceFit', 'alice@athly.fr'); + bob = await createAndLoginUser('BobMuscle', 'bob@athly.fr'); + carol = await createAndLoginUser('CarolGains','carol@athly.fr'); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 1. Recherche d'utilisateurs + // ─────────────────────────────────────────────────────────────────────────── + describe('GET /api/friends/search — searchUsers', () => { + + it('✅ Trouve les pseudos correspondants avec le statut de relation', async () => { + await makeFriends(alice.userId, bob.userId); + + const res = await request(app) + .get('/api/friends/search?q=bob') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.results[0].user.pseudo).toBe('BobMuscle'); + expect(res.body.results[0].relationStatus).toBe('accepted'); + }); + + it("✅ relationStatus pending_sent/received selon le sens de la demande", async () => { + await Friendship.create({ requester: alice.userId, recipient: carol.userId, status: 'pending' }); + + const fromAlice = await request(app) + .get('/api/friends/search?q=carol') + .set('Authorization', `Bearer ${alice.token}`); + expect(fromAlice.body.results[0].relationStatus).toBe('pending_sent'); + + const fromCarol = await request(app) + .get('/api/friends/search?q=alice') + .set('Authorization', `Bearer ${carol.token}`); + expect(fromCarol.body.results[0].relationStatus).toBe('pending_received'); + }); + + it('✅ Ne se trouve jamais soi-même dans les résultats', async () => { + const res = await request(app) + .get('/api/friends/search?q=alice') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.body.results.every((r) => r.user.pseudo !== 'AliceFit')).toBe(true); + }); + + it('❌ 400 si la recherche fait moins de 2 caractères', async () => { + const res = await request(app) + .get('/api/friends/search?q=a') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(400); + }); + + it('🔒 Les caractères regex sont neutralisés (pas d\'injection de pattern)', async () => { + const res = await request(app) + .get('/api/friends/search?q=' + encodeURIComponent('.*')) + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(200); + expect(res.body.count).toBe(0); // ".*" littéral ne matche aucun pseudo + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 2. Profil public d'un ami + // ─────────────────────────────────────────────────────────────────────────── + describe('GET /api/friends/profile/:friendId — getFriendProfile', () => { + + it('✅ Renvoie le profil complet pour un ami accepté', async () => { + await makeFriends(alice.userId, bob.userId); + await User.updateOne({ _id: bob.userId }, { level: 15, xp: 15000, rank: 'Initié' }); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.profile.user.pseudo).toBe('BobMuscle'); + expect(res.body.profile.user.level).toBe(15); + expect(res.body.profile.friendshipLevel).toBe(1); + expect(res.body.profile.user.password).toBeUndefined(); + expect(res.body.profile.user.email).toBeUndefined(); + }); + + it("❌ 403 si la relation n'est pas acceptée (aucune fuite entre inconnus)", async () => { + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(403); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 3. Classement dynamique + // ─────────────────────────────────────────────────────────────────────────── + describe('GET /api/friends/leaderboard — getLeaderboard', () => { + + it('✅ Classe moi + mes amis par XP décroissant avec positions', async () => { + await makeFriends(alice.userId, bob.userId); + await makeFriends(alice.userId, carol.userId); + await User.updateOne({ _id: alice.userId }, { xp: 500 }); + await User.updateOne({ _id: bob.userId }, { xp: 2000 }); + await User.updateOne({ _id: carol.userId }, { xp: 1000 }); + + const res = await request(app) + .get('/api/friends/leaderboard') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.count).toBe(3); + expect(res.body.leaderboard[0].user.pseudo).toBe('BobMuscle'); + expect(res.body.leaderboard[0].position).toBe(1); + expect(res.body.leaderboard[2].user.pseudo).toBe('AliceFit'); + expect(res.body.leaderboard[2].isMe).toBe(true); + }); + + it("✅ Le classement n'inclut pas les non-amis", async () => { + await makeFriends(alice.userId, bob.userId); + // carol n'est pas amie avec alice + + const res = await request(app) + .get('/api/friends/leaderboard') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.count).toBe(2); + expect(res.body.leaderboard.every((e) => e.user.pseudo !== 'CarolGains')).toBe(true); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 4. Coffres à l'effort (minutes de séance cumulées) + // ─────────────────────────────────────────────────────────────────────────── + describe('Pipeline minutes → CHEST_KEY (finalizeWorkout)', () => { + + async function finalizeWorkoutOf(user, durationSeconds) { + const draft = await Workout.create({ + user: user.userId, name: 'Séance', status: 'in_progress', date: new Date(), + }); + return request(app) + .post(`/api/workouts/${draft._id}/finalize`) + .set('Authorization', `Bearer ${user.token}`) + .send({ durationSeconds }); + } + + it('✅ Franchir un palier de 300 min au niveau 11+ attribue une CHEST_KEY', async () => { + await User.updateOne( + { _id: alice.userId }, + { level: 11, xp: 2000, totalWorkoutMinutes: 290 }, + ); + + const res = await finalizeWorkoutOf(alice, 1200); // +20 min → 310 total + expect(res.statusCode).toBe(200); + expect(res.body.stats.chestsAwarded).toBe(1); + + const user = await User.findById(alice.userId); + expect(user.totalWorkoutMinutes).toBe(310); + const key = user.inventory.find((i) => i.itemType === 'CHEST_KEY'); + expect(key).toBeDefined(); + expect(key.quantity).toBe(1); + }); + + it('🔒 Sous le niveau 11 : les minutes s\'accumulent mais AUCUN coffre ne drop', async () => { + await User.updateOne( + { _id: alice.userId }, + { level: 5, xp: 500, totalWorkoutMinutes: 290 }, + ); + + const res = await finalizeWorkoutOf(alice, 1200); + expect(res.statusCode).toBe(200); + expect(res.body.stats.chestsAwarded).toBe(0); + + const user = await User.findById(alice.userId); + expect(user.totalWorkoutMinutes).toBe(310); + expect(user.inventory.find((i) => i.itemType === 'CHEST_KEY')).toBeUndefined(); + }); + + it('✅ Aucun palier franchi → aucun coffre', async () => { + await User.updateOne( + { _id: alice.userId }, + { level: 11, xp: 2000, totalWorkoutMinutes: 10 }, + ); + + const res = await finalizeWorkoutOf(alice, 1200); + expect(res.body.stats.chestsAwarded).toBe(0); + }); + + it('🔒 Une séance courte (< 300 s) n\'alimente pas le compteur (anti-cheat)', async () => { + await User.updateOne( + { _id: alice.userId }, + { level: 11, xp: 2000, totalWorkoutMinutes: 299 }, + ); + + const res = await finalizeWorkoutOf(alice, 120); + expect(res.statusCode).toBe(200); + expect(res.body.stats.chestsAwarded).toBe(0); + + const user = await User.findById(alice.userId); + expect(user.totalWorkoutMinutes).toBe(299); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 5. Streak de groupe : bonus XP multiplié par la taille + // ─────────────────────────────────────────────────────────────────────────── + describe('Streak de groupe — bonus XP et niveau d\'amitié', () => { + + async function createValidatedGroup(members) { + const group = await StreakGroup.create({ + name: 'Team Test', + members: members.map((m) => m.userId), + pendingInvites: [], + }); + await Promise.all(members.map((m) => finishedWorkoutToday(m.userId))); + return group; + } + + it('✅ Validation 100% : streak +1, bonus XP multiplié par la taille du groupe', async () => { + await makeFriends(alice.userId, bob.userId); + const group = await createValidatedGroup([alice, bob]); + + const res = await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.allValidated).toBe(true); + expect(res.body.currentStreak).toBe(1); + // 2 membres → multiplicateur x1.25 → 40 * 1.25 = 50 XP + expect(res.body.groupBonus.multiplier).toBeCloseTo(1.25); + expect(res.body.groupBonus.bonusXp).toBe(50); + + const aliceDb = await User.findById(alice.userId); + const bobDb = await User.findById(bob.userId); + expect(aliceDb.xp).toBe(50); + expect(bobDb.xp).toBe(50); + }); + + it('✅ Un membre manquant → pas de streak, pas de bonus', async () => { + await makeFriends(alice.userId, bob.userId); + const group = await StreakGroup.create({ + name: 'Team Test', + members: [alice.userId, bob.userId], pendingInvites: [], + }); + await finishedWorkoutToday(alice.userId); // bob n'a rien fait + + const res = await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.allValidated).toBe(false); + expect(res.body.pendingMembers).toContain(bob.userId); + + const aliceDb = await User.findById(alice.userId); + expect(aliceDb.xp).toBe(0); + }); + + it('🏆 Passage au niveau d\'amitié 5 : cadre Unique injecté chez les DEUX amis + trophée', async () => { + // Amitié à 1499 XP (juste sous le seuil de 1500 du niveau 5) + await Friendship.create({ + requester: alice.userId, recipient: bob.userId, + status: 'accepted', friendshipXp: 1499, friendshipLevel: 4, + }); + const group = await createValidatedGroup([alice, bob]); + + const res = await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.allValidated).toBe(true); + + const friendship = await Friendship.findOne({ requester: alice.userId }); + expect(friendship.friendshipLevel).toBe(5); + + for (const u of [alice, bob]) { + const doc = await User.findById(u.userId); + const frame = doc.inventory.find((i) => i.itemType === 'PROFILE_FRAME_BLOOD_BOND'); + expect(frame).toBeDefined(); + expect(frame.rarity).toBe('unique'); + expect(frame.quantity).toBe(1); + + const trophy = doc.achievements.find((a) => a.achievementId === 'FRIENDSHIP_LEVEL_5'); + expect(trophy).toBeDefined(); + } + }); + + it('🔒 Le cadre Unique n\'est jamais dupliqué (idempotence)', async () => { + await Friendship.create({ + requester: alice.userId, recipient: bob.userId, + status: 'accepted', friendshipXp: 2000, friendshipLevel: 5, + }); + await User.updateOne( + { _id: alice.userId }, + { inventory: [{ itemType: 'PROFILE_FRAME_BLOOD_BOND', rarity: 'unique', quantity: 1 }] }, + ); + const group = await createValidatedGroup([alice, bob]); + + await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + const doc = await User.findById(alice.userId); + const frames = doc.inventory.filter((i) => i.itemType === 'PROFILE_FRAME_BLOOD_BOND'); + expect(frames).toHaveLength(1); + expect(frames[0].quantity).toBe(1); + }); + + it("✅ getMyGroup expose les invitations reçues (pendingInvites) à l'invité", async () => { + await StreakGroup.create({ + name: 'Team Recrutement', + members: [bob.userId], + pendingInvites: [alice.userId], + }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.group).toBeNull(); + expect(res.body.invites).toHaveLength(1); + expect(res.body.invites[0].name).toBe('Team Recrutement'); + }); + }); +}); diff --git a/front/src/components/inventory/ChestOpeningModal.js b/front/src/components/inventory/ChestOpeningModal.js new file mode 100644 index 0000000..3ef876f --- /dev/null +++ b/front/src/components/inventory/ChestOpeningModal.js @@ -0,0 +1,195 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated, Easing } from 'react-native'; +import { Colors } from '../../constants/theme'; +import { ITEM_CATALOG, RARITY_META } from '../../services/inventory.service'; +import BirthdayConfetti from '../profile/BirthdayConfetti'; + +// ─── ChestOpeningModal ──────────────────────────────────────────────────────── +// Séquence d'ouverture de coffre en 3 phases, 100% Animated (useNativeDriver) : +// 1. "shaking" — le coffre tremble avec une intensité croissante +// 2. "burst" — flash + explosion d'échelle, halo à la couleur de la rareté +// 3. "reveal" — l'item tiré apparaît en rebond + confettis si épique+ +// +// Props : +// visible bool +// drawnItem { itemType, rarity } | null — résultat renvoyé par le backend +// onClose () => void + +export default function ChestOpeningModal({ visible, drawnItem, onClose }) { + const [phase, setPhase] = useState('shaking'); // shaking | burst | reveal + + const shake = useRef(new Animated.Value(0)).current; + const chestScale = useRef(new Animated.Value(1)).current; + const flash = useRef(new Animated.Value(0)).current; + const itemScale = useRef(new Animated.Value(0)).current; + const itemSpin = useRef(new Animated.Value(0)).current; + const glow = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!visible) return; + + setPhase('shaking'); + shake.setValue(0); + chestScale.setValue(1); + flash.setValue(0); + itemScale.setValue(0); + itemSpin.setValue(0); + glow.setValue(0); + + // Phase 1 : tremblements croissants (3 salves) + const wobble = (intensity, duration) => + Animated.sequence([ + Animated.timing(shake, { toValue: intensity, duration: duration / 4, useNativeDriver: true }), + Animated.timing(shake, { toValue: -intensity, duration: duration / 2, useNativeDriver: true }), + Animated.timing(shake, { toValue: 0, duration: duration / 4, useNativeDriver: true }), + ]); + + Animated.sequence([ + wobble(4, 300), + Animated.delay(120), + wobble(8, 260), + Animated.delay(80), + wobble(14, 220), + // Phase 2 : compression puis explosion + flash + Animated.parallel([ + Animated.sequence([ + Animated.timing(chestScale, { toValue: 0.82, duration: 130, easing: Easing.in(Easing.quad), useNativeDriver: true }), + Animated.timing(chestScale, { toValue: 1.9, duration: 240, easing: Easing.out(Easing.back(2)), useNativeDriver: true }), + ]), + Animated.sequence([ + Animated.delay(130), + Animated.timing(flash, { toValue: 1, duration: 110, useNativeDriver: true }), + ]), + ]), + ]).start(() => { + setPhase('reveal'); + // Phase 3 : reveal de l'item en rebond + halo pulsé + Animated.parallel([ + Animated.timing(flash, { toValue: 0, duration: 350, useNativeDriver: true }), + Animated.spring(itemScale, { toValue: 1, friction: 4, tension: 60, useNativeDriver: true }), + Animated.timing(itemSpin, { toValue: 1, duration: 550, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.loop( + Animated.sequence([ + Animated.timing(glow, { toValue: 1, duration: 900, useNativeDriver: true }), + Animated.timing(glow, { toValue: 0.35, duration: 900, useNativeDriver: true }), + ]), + ), + ]).start(); + }); + + return () => { + shake.stopAnimation(); + chestScale.stopAnimation(); + flash.stopAnimation(); + itemScale.stopAnimation(); + itemSpin.stopAnimation(); + glow.stopAnimation(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + const item = drawnItem ? ITEM_CATALOG[drawnItem.itemType] : null; + const rarity = drawnItem ? RARITY_META[drawnItem.rarity] : null; + const isBigDrop = drawnItem && ['epic', 'legendary'].includes(drawnItem.rarity); + + const spinDeg = itemSpin.interpolate({ inputRange: [0, 1], outputRange: ['-180deg', '0deg'] }); + + return ( + + + {phase === 'reveal' && isBigDrop && } + + {/* Flash blanc de l'explosion */} + + + {phase !== 'reveal' ? ( + + 📦 + + ) : ( + + {/* Halo pulsé à la couleur de la rareté */} + + + {item?.emoji ?? '❔'} + + + + {rarity?.label?.toUpperCase() ?? ''} + + {item?.name ?? drawnItem?.itemType} + {item?.description ?? ''} + + + Récupérer + + + )} + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.92)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, + }, + flash: { backgroundColor: '#FFFFFF' }, + chest: { fontSize: 96 }, + + revealWrap: { alignItems: 'center', width: '100%' }, + halo: { + position: 'absolute', + top: -70, + width: 260, + height: 260, + borderRadius: 130, + }, + itemEmoji: { fontSize: 84, marginBottom: 18 }, + rarityLabel: { + fontSize: 13, + fontWeight: '800', + letterSpacing: 3, + marginBottom: 6, + }, + itemName: { + color: Colors.textPrimary, + fontSize: 22, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 8, + textAlign: 'center', + }, + itemDesc: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + marginBottom: 30, + }, + collectBtn: { + height: 50, + paddingHorizontal: 40, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + }, + collectTxt: { color: '#fff', fontSize: 15, fontWeight: '800', letterSpacing: 0.3 }, +}); diff --git a/front/src/navigation/BottomTabs.js b/front/src/navigation/BottomTabs.js index 1117b70..980455a 100644 --- a/front/src/navigation/BottomTabs.js +++ b/front/src/navigation/BottomTabs.js @@ -3,6 +3,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import HomeScreen from '../screens/Home/HomeScreen'; import WorkoutStack from './WorkoutStack'; import StatsScreen from '../screens/Stats/StatsScreen'; +import SocialStack from './SocialStack'; import ProfileStack from './ProfileStack'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../constants/theme'; @@ -22,6 +23,7 @@ export default function BottomTabs() { let icon = 'home'; if (route.name === 'Séances') icon = 'barbell'; if (route.name === 'Stats') icon = 'stats-chart'; + if (route.name === 'SocialTab') icon = 'people'; if (route.name === 'ProfileTab') icon = 'person'; return ; }, @@ -30,8 +32,13 @@ export default function BottomTabs() { - + ); -} \ No newline at end of file +} diff --git a/front/src/navigation/ProfileStack.js b/front/src/navigation/ProfileStack.js index 1504db6..e00ed6c 100644 --- a/front/src/navigation/ProfileStack.js +++ b/front/src/navigation/ProfileStack.js @@ -5,6 +5,7 @@ import EditProfileScreen from '../screens/Profile/EditProfileScreen'; import RankRoadmapScreen from '../screens/Profile/RankRoadmapScreen'; import TrophyRoomScreen from '../screens/Profile/TrophyRoomScreen'; import SettingsScreen from '../screens/Profile/SettingsScreen'; +import InventoryScreen from '../screens/Profile/InventoryScreen'; import { Colors } from '../constants/theme'; const Stack = createStackNavigator(); @@ -57,6 +58,11 @@ export default function ProfileStack() { component={SettingsScreen} options={{ title: 'Réglages' }} /> + ); } diff --git a/front/src/navigation/SocialStack.js b/front/src/navigation/SocialStack.js new file mode 100644 index 0000000..563d286 --- /dev/null +++ b/front/src/navigation/SocialStack.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { createStackNavigator } from '@react-navigation/stack'; +import SocialScreen from '../screens/Social/SocialScreen'; +import FriendProfileScreen from '../screens/Social/FriendProfileScreen'; + +const Stack = createStackNavigator(); + +// Les deux écrans gèrent leur propre header (fond abyss + chevron custom) +export default function SocialStack() { + return ( + + + + + ); +} diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js index dafbc65..34c54ff 100644 --- a/front/src/navigation/index.js +++ b/front/src/navigation/index.js @@ -19,6 +19,7 @@ import { UserProvider } from '../context/UserContext'; import { TutorialProvider } from '../context/TutorialContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { setupNotificationChannels, ensureDailyRemindersScheduled } from '../services/notificationService'; +import BirthdayCelebration from '../components/profile/BirthdayCelebration'; const NOTIF_ENABLED_KEY = 'athly:notif:enabled:v1'; diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js new file mode 100644 index 0000000..8a523ac --- /dev/null +++ b/front/src/screens/Profile/InventoryScreen.js @@ -0,0 +1,326 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { + View, Text, StyleSheet, ScrollView, TouchableOpacity, + StatusBar, ActivityIndicator, Animated, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; +import { Colors } from '../../constants/theme'; +import { useUser } from '../../context/UserContext'; +import { useToast } from '../../context/ToastContext'; +import { openChest, useItem, ITEM_CATALOG, RARITY_META } from '../../services/inventory.service'; +import ChestOpeningModal from '../../components/inventory/ChestOpeningModal'; + +const MIN_LEVEL_FOR_CHEST = 11; +const RARITY_ORDER = ['unique', 'legendary', 'epic', 'rare', 'common']; + +// ─── InventoryScreen ────────────────────────────────────────────────────────── +// Inventaire RPG (Brique II) : coffres à ouvrir, consommables par rareté, +// cosmétiques Uniques. Chaque carte entre en scène en cascade (stagger). + +export default function InventoryScreen({ navigation }) { + const { user, refetch } = useUser(); + const { showToast } = useToast(); + + const [busy, setBusy] = useState(false); + const [chestModal, setChestModal] = useState({ visible: false, drawnItem: null }); + + useFocusEffect(useCallback(() => { refetch(); }, [refetch])); + + const inventory = user?.inventory ?? []; + const chestEntry = inventory.find((i) => i.itemType === 'CHEST_KEY'); + const chestCount = chestEntry?.quantity ?? 0; + const level = user?.level ?? 1; + const chestLocked = level < MIN_LEVEL_FOR_CHEST; + + // Items hors coffres, groupés par rareté (ordre : unique → commun) + const items = inventory + .filter((i) => i.itemType !== 'CHEST_KEY' && i.quantity > 0) + .sort((a, b) => RARITY_ORDER.indexOf(a.rarity) - RARITY_ORDER.indexOf(b.rarity)); + + const handleOpenChest = async () => { + if (busy) return; + setBusy(true); + try { + const res = await openChest(); + if (res.success) { + // La modale joue l'animation (shake → burst → reveal) + setChestModal({ visible: true, drawnItem: res.drawnItem }); + } + } catch (error) { + if (error.isSessionExpired) return; + showToast(error.data?.message || 'Impossible d\'ouvrir le coffre.', 'error'); + } finally { + setBusy(false); + } + }; + + const handleUseItem = async (itemType) => { + if (busy) return; + setBusy(true); + try { + const res = await useItem(itemType); + if (res.success) { + showToast(`${ITEM_CATALOG[itemType]?.name ?? itemType} utilisé ! ✨`, 'success'); + refetch(); + } + } catch (error) { + if (error.isSessionExpired) return; + showToast(error.data?.message || 'Impossible d\'utiliser cet objet.', 'error'); + } finally { + setBusy(false); + } + }; + + const closeChestModal = () => { + setChestModal({ visible: false, drawnItem: null }); + refetch(); + }; + + return ( + + + + {/* ── Header ── */} + + navigation.goBack()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + + + Inventaire + + + + + + {/* ── Coffres ── */} + + + {/* ── Objets ── */} + MES OBJETS + {items.length === 0 ? ( + + 🎒 + + Ton sac est vide.{'\n'}Enchaîne les séances pour gagner des coffres ! + + + ) : ( + items.map((entry, index) => ( + handleUseItem(entry.itemType)} + /> + )) + )} + + + + + + + ); +} + +// ─── Carte coffre avec flottement continu ──────────────────────────────────── + +function ChestCard({ count, locked, busy, onOpen }) { + const float = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(float, { toValue: -6, duration: 1400, useNativeDriver: true }), + Animated.timing(float, { toValue: 0, duration: 1400, useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const canOpen = !locked && count > 0 && !busy; + + return ( + + + {locked ? '🔒' : '📦'} + + + + + {locked ? 'Coffres verrouillés' : `${count} coffre${count > 1 ? 's' : ''} disponible${count > 1 ? 's' : ''}`} + + + {locked + ? `Atteins le niveau ${MIN_LEVEL_FOR_CHEST} (Rang Initié) pour les débloquer.` + : 'Cumule 5 h de séance pour gagner un coffre.'} + + + + + {busy + ? + : Ouvrir} + + + ); +} + +// ─── Carte item avec entrée en cascade ─────────────────────────────────────── + +function StaggeredItemCard({ entry, index, busy, onUse }) { + const slide = useRef(new Animated.Value(24)).current; + const fade = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(slide, { + toValue: 0, duration: 320, delay: index * 70, useNativeDriver: true, + }), + Animated.timing(fade, { + toValue: 1, duration: 320, delay: index * 70, useNativeDriver: true, + }), + ]).start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const meta = ITEM_CATALOG[entry.itemType] ?? {}; + const rarity = RARITY_META[entry.rarity] ?? RARITY_META.common; + + return ( + + + {meta.emoji ?? '❔'} + + + + + {meta.name ?? entry.itemType} + {entry.quantity > 1 && ×{entry.quantity}} + + {rarity.label} + {meta.description} + + + {meta.usable && ( + + Utiliser + + )} + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: Colors.bgAbyss }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 54, + paddingBottom: 14, + }, + headerTitle: { color: Colors.textPrimary, fontSize: 17, fontWeight: '800', letterSpacing: 0.2 }, + content: { paddingHorizontal: 16 }, + + sectionLabel: { + color: Colors.textMuted, fontSize: 11, fontWeight: '700', + letterSpacing: 0.8, marginTop: 26, marginBottom: 10, marginLeft: 4, + }, + + // ── Coffre ── + chestCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(254,116,57,0.07)', + borderWidth: 1, + borderColor: 'rgba(254,116,57,0.30)', + borderRadius: 18, + padding: 18, + marginTop: 8, + }, + chestCardLocked: { + backgroundColor: 'rgba(255,255,255,0.04)', + borderColor: 'rgba(255,255,255,0.09)', + }, + chestEmoji: { fontSize: 40, marginRight: 14 }, + chestInfo: { flex: 1, marginRight: 10 }, + chestTitle: { color: Colors.textPrimary, fontSize: 15, fontWeight: '800', marginBottom: 3 }, + chestSub: { color: Colors.textSecondary, fontSize: 12, lineHeight: 17 }, + openBtn: { + backgroundColor: Colors.primary, + borderRadius: 11, + paddingHorizontal: 18, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + openBtnDisabled: { opacity: 0.35 }, + openBtnTxt: { color: '#fff', fontSize: 13, fontWeight: '800' }, + + // ── Items ── + itemCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.cardDeep, + borderWidth: 1, + borderRadius: 16, + padding: 14, + marginBottom: 10, + }, + itemIconBox: { + width: 52, height: 52, borderRadius: 14, + justifyContent: 'center', alignItems: 'center', marginRight: 12, + }, + itemEmoji: { fontSize: 26 }, + itemInfo: { flex: 1, marginRight: 10 }, + itemNameRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + itemName: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' }, + itemQty: { color: Colors.textSecondary, fontSize: 13, fontWeight: '800' }, + itemRarity:{ fontSize: 11, fontWeight: '800', letterSpacing: 1, marginTop: 1, marginBottom: 3 }, + itemDesc: { color: Colors.textMuted, fontSize: 11.5, lineHeight: 16 }, + useBtn: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 13, + height: 34, + justifyContent: 'center', + }, + useBtnTxt: { fontSize: 12, fontWeight: '800' }, + + // ── Vide ── + emptyBox: { alignItems: 'center', paddingVertical: 40 }, + emptyEmoji: { fontSize: 40, marginBottom: 12 }, + emptyTxt: { color: Colors.textMuted, fontSize: 13, lineHeight: 20, textAlign: 'center' }, +}); diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js index 3e22c51..9a8367b 100644 --- a/front/src/screens/Profile/ProfileScreen.js +++ b/front/src/screens/Profile/ProfileScreen.js @@ -248,6 +248,12 @@ export default function ProfileScreen({ navigation }) { onPress={() => navigation && navigation.navigate('TrophyRoom')} accentColor={isElite ? rank.color : null} /> + navigation && navigation.navigate('Inventory')} + accentColor={isElite ? rank.color : null} + /> {/* ── Vitrine de trophées ── */} diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js new file mode 100644 index 0000000..da07a51 --- /dev/null +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -0,0 +1,169 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + View, Text, StyleSheet, ScrollView, TouchableOpacity, + StatusBar, ActivityIndicator, Animated, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import { useToast } from '../../context/ToastContext'; +import { getFriendProfile } from '../../services/social.service'; + +// ─── FriendProfileScreen ────────────────────────────────────────────────────── +// Profil public d'un ami (Brique III) : progression, lien d'amitié, records. +// Le backend refuse (403) si la relation n'est pas acceptée. + +export default function FriendProfileScreen({ route, navigation }) { + const { friendId, pseudo } = route.params ?? {}; + const { showToast } = useToast(); + + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + + const fade = useRef(new Animated.Value(0)).current; + + useEffect(() => { + (async () => { + try { + const res = await getFriendProfile(friendId); + setProfile(res.profile); + Animated.timing(fade, { toValue: 1, duration: 360, useNativeDriver: true }).start(); + } catch (error) { + if (!error.isSessionExpired) { + showToast(error.data?.message || 'Profil inaccessible.', 'error'); + } + navigation.goBack(); + } finally { + setLoading(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [friendId]); + + return ( + + + + + navigation.goBack()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + + + {pseudo ?? 'Profil'} + + + + {loading ? ( + + ) : profile && ( + + + + {/* ── Identité de jeu ── */} + + + {(profile.user.pseudo ?? '?').charAt(0).toUpperCase()} + + {profile.user.pseudo} + {profile.user.rank} · Niveau {profile.user.level} + + + + + + + + + {/* ── Lien d'amitié ── */} + + + {'❤️'.repeat(profile.friendshipLevel)}{'🤍'.repeat(Math.max(0, 5 - profile.friendshipLevel))} + + + Niveau d'amitié {profile.friendshipLevel}/5 + {profile.friendshipLevel === 5 ? ' — Lien de Sang 🩸' : ''} + + {profile.friendshipXp} XP d'amitié + + + {/* ── Records ── */} + RECORDS + {profile.records.length === 0 ? ( + Aucun record enregistré pour le moment. + ) : profile.records.map((r) => ( + + + {r.exercice} + {r.maxPoids} kg × {r.maxReps} + + ))} + + + + + )} + + ); +} + +function Stat({ label, value }) { + return ( + + {value} + {label} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: Colors.bgAbyss }, + header: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingHorizontal: 16, paddingTop: 54, paddingBottom: 14, + }, + headerTitle: { color: Colors.textPrimary, fontSize: 17, fontWeight: '800' }, + loadingBox: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + content: { paddingHorizontal: 16 }, + + heroCard: { + alignItems: 'center', + backgroundColor: Colors.cardDeep, + borderWidth: 1, borderColor: Colors.borderSubtle, + borderRadius: 18, padding: 22, marginTop: 6, + }, + bigAvatar: { + width: 68, height: 68, borderRadius: 20, + backgroundColor: 'rgba(254,116,57,0.14)', + justifyContent: 'center', alignItems: 'center', marginBottom: 12, + }, + bigAvatarTxt: { color: Colors.primary, fontSize: 28, fontWeight: '800' }, + pseudo: { color: Colors.textPrimary, fontSize: 20, fontWeight: '800', letterSpacing: -0.3 }, + rank: { color: Colors.textSecondary, fontSize: 13, marginTop: 3, marginBottom: 16 }, + + statRow: { flexDirection: 'row', width: '100%' }, + stat: { flex: 1, alignItems: 'center' }, + statValue: { color: Colors.textPrimary, fontSize: 17, fontWeight: '800' }, + statLabel: { color: Colors.textMuted, fontSize: 11, marginTop: 2 }, + + friendshipCard: { + alignItems: 'center', + backgroundColor: 'rgba(139,92,246,0.07)', + borderWidth: 1, borderColor: 'rgba(139,92,246,0.28)', + borderRadius: 16, padding: 16, marginTop: 12, + }, + friendshipHearts: { fontSize: 16, letterSpacing: 3, marginBottom: 6 }, + friendshipLevel: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '700' }, + friendshipXp: { color: Colors.textMuted, fontSize: 11.5, marginTop: 2 }, + + sectionLabel: { + color: Colors.textMuted, fontSize: 11, fontWeight: '700', + letterSpacing: 0.8, marginTop: 22, marginBottom: 8, marginLeft: 4, + }, + recordRow: { + flexDirection: 'row', alignItems: 'center', + backgroundColor: Colors.cardDeep, + borderWidth: 1, borderColor: Colors.borderSubtle, + borderRadius: 12, paddingHorizontal: 14, height: 46, marginBottom: 7, + }, + recordName: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600', marginRight: 8 }, + recordValue: { color: Colors.primary, fontSize: 13, fontWeight: '800' }, + emptyTxt: { color: Colors.textMuted, fontSize: 12.5, marginLeft: 4 }, +}); diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js new file mode 100644 index 0000000..69de018 --- /dev/null +++ b/front/src/screens/Social/SocialScreen.js @@ -0,0 +1,689 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { + View, Text, StyleSheet, ScrollView, TouchableOpacity, TextInput, + StatusBar, ActivityIndicator, Animated, RefreshControl, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; +import { Colors } from '../../constants/theme'; +import { useToast } from '../../context/ToastContext'; +import { + searchUsers, sendFriendRequest, acceptFriendRequest, declineFriendRequest, + getFriendsList, getPendingRequests, getLeaderboard, + getMyGroup, inviteToGroup, respondToGroupInvite, shakeMember, checkGroupStreak, +} from '../../services/social.service'; + +const SEGMENTS = [ + { key: 'friends', label: 'Amis', icon: 'people' }, + { key: 'leaderboard', label: 'Classement', icon: 'podium' }, + { key: 'group', label: 'Groupe', icon: 'flame' }, +]; + +// ─── SocialScreen ───────────────────────────────────────────────────────────── +// Hub social (Briques III & IV) : amis + demandes + recherche, classement XP, +// groupe de streak avec bouton Secouer. Chaque segment a ses entrées animées. + +export default function SocialScreen({ navigation }) { + const { showToast } = useToast(); + const [segment, setSegment] = useState('friends'); + + // ── Données ── + const [friends, setFriends] = useState([]); + const [pending, setPending] = useState([]); + const [leaderboard, setLeaderboard] = useState([]); + const [group, setGroup] = useState(null); + const [groupInvites, setGroupInvites] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // ── Recherche ── + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); // null = pas de recherche active + const [searching, setSearching] = useState(false); + const searchTimer = useRef(null); + + const loadAll = useCallback(async () => { + try { + const [friendsRes, pendingRes, boardRes, groupRes] = await Promise.all([ + getFriendsList(), getPendingRequests(), getLeaderboard(), getMyGroup(), + ]); + setFriends(friendsRes.friends ?? []); + setPending(pendingRes.requests ?? []); + setLeaderboard(boardRes.leaderboard ?? []); + setGroup(groupRes.group ?? null); + setGroupInvites(groupRes.invites ?? []); + } catch (error) { + if (!error.isSessionExpired) { + showToast('Impossible de charger le social. Vérifie ta connexion.', 'error'); + } + } finally { + setLoading(false); + setRefreshing(false); + } + }, [showToast]); + + useFocusEffect(useCallback(() => { loadAll(); }, [loadAll])); + + const onRefresh = () => { setRefreshing(true); loadAll(); }; + + // ── Recherche débouncée (400 ms) ── + const onQueryChange = (text) => { + setQuery(text); + if (searchTimer.current) clearTimeout(searchTimer.current); + if (text.trim().length < 2) { setResults(null); return; } + searchTimer.current = setTimeout(async () => { + setSearching(true); + try { + const res = await searchUsers(text.trim()); + setResults(res.results ?? []); + } catch (_) { + setResults([]); + } finally { + setSearching(false); + } + }, 400); + }; + + useEffect(() => () => searchTimer.current && clearTimeout(searchTimer.current), []); + + // ── Actions amis ── + const doAction = async (fn, successMsg) => { + try { + await fn(); + if (successMsg) showToast(successMsg, 'success'); + loadAll(); + if (query.trim().length >= 2) onQueryChange(query); // rafraîchit la recherche + } catch (error) { + if (error.isSessionExpired) return; + showToast(error.data?.message || 'Action impossible.', 'error'); + } + }; + + return ( + + + + Social + + {/* ── Segments ── */} + + {SEGMENTS.map((s) => { + const active = segment === s.key; + const badge = s.key === 'friends' && pending.length > 0 ? pending.length + : s.key === 'group' && groupInvites.length > 0 ? groupInvites.length + : null; + return ( + setSegment(s.key)} + activeOpacity={0.8} + > + + {s.label} + {badge != null && ( + {badge} + )} + + ); + })} + + + {loading ? ( + + ) : ( + } + keyboardShouldPersistTaps="handled" + > + {segment === 'friends' && ( + doAction(() => sendFriendRequest(id), 'Invitation envoyée ⚡')} + onAccept={(id) => doAction(() => acceptFriendRequest(id), 'Vous êtes maintenant amis ! 🤝')} + onDecline={(id) => doAction(() => declineFriendRequest(id))} + onOpenProfile={(friend, friendshipLevel) => + navigation.navigate('FriendProfile', { friendId: friend._id, pseudo: friend.pseudo, friendshipLevel })} + /> + )} + + {segment === 'leaderboard' && } + + {segment === 'group' && ( + doAction(() => inviteToGroup(ids, name), 'Demande de Streak de Groupe envoyée 🔥')} + onRespond={(groupId, accept) => + doAction(() => respondToGroupInvite(groupId, accept), accept ? 'Bienvenue dans le groupe ! 🔥' : null)} + onShake={(groupId, memberId, pseudo) => + doAction(() => shakeMember(groupId, memberId), `${pseudo} a été secoué ! 🚨`)} + onCheckStreak={async (groupId) => { + try { + const res = await checkGroupStreak(groupId); + if (res.allValidated) { + showToast(`Streak jour ${res.currentStreak} ! +${res.groupBonus?.bonusXp ?? 0} XP (x${res.groupBonus?.multiplier ?? 1}) 🔥`, 'success'); + } else if (res.alreadyValidated) { + showToast('Déjà validée aujourd\'hui ✅', 'success'); + } else { + showToast('Tous les membres n\'ont pas encore validé leur séance.', 'error'); + } + loadAll(); + } catch (error) { + if (!error.isSessionExpired) showToast(error.data?.message || 'Erreur.', 'error'); + } + }} + /> + )} + + + + )} + + ); +} + +// ═══ Segment Amis ═════════════════════════════════════════════════════════════ + +function FriendsSegment({ + friends, pending, query, results, searching, + onQueryChange, onSend, onAccept, onDecline, onOpenProfile, +}) { + return ( + <> + {/* ── Recherche ── */} + + + + {searching && } + + + {results !== null && ( + <> + RÉSULTATS + {results.length === 0 && !searching ? ( + Aucun athlète trouvé. + ) : results.map((r, i) => ( + + + {r.relationStatus === 'none' && ( + onSend(r.user._id)} /> + )} + {r.relationStatus === 'pending_sent' && Envoyée ✓} + {r.relationStatus === 'pending_received' && ( + onAccept(r.requestId)} /> + )} + {r.relationStatus === 'accepted' && Ami 🤝} + + + ))} + + )} + + {/* ── Demandes reçues ── */} + {pending.length > 0 && ( + <> + DEMANDES REÇUES + {pending.map((req, i) => ( + + + onAccept(req._id)} /> + onDecline(req._id)} /> + + + ))} + + )} + + {/* ── Mes amis ── */} + MES AMIS ({friends.length}) + {friends.length === 0 ? ( + + 🤝 + Pas encore d'amis.{'\n'}Cherche un pseudo ci-dessus pour commencer ! + + ) : friends.map((f, i) => ( + + onOpenProfile(f.user, f.friendshipLevel)}> + + + + + + + ))} + + ); +} + +// ═══ Segment Classement ═══════════════════════════════════════════════════════ + +const PODIUM_COLORS = ['#FFD700', '#C0C0C0', '#CD7F32']; + +function LeaderboardSegment({ leaderboard }) { + const podium = leaderboard.slice(0, 3); + const rest = leaderboard.slice(3); + + return ( + <> + {leaderboard.length < 2 ? ( + + 🏆 + Ajoute des amis pour lancer la compétition ! + + ) : ( + <> + {/* ── Podium ── */} + + {[1, 0, 2].map((idx) => { + const entry = podium[idx]; + if (!entry) return ; + return ( + + ); + })} + + + {/* ── Reste du classement ── */} + {rest.map((entry, i) => ( + + + #{entry.position} + + {entry.user.pseudo}{entry.isMe ? ' (moi)' : ''} + + {entry.user.xp} XP + + + ))} + + )} + + ); +} + +function PodiumColumn({ entry, height, color, delay }) { + const grow = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.spring(grow, { toValue: 1, friction: 6, tension: 50, delay, useNativeDriver: true }).start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {entry.user.pseudo} + {entry.user.xp} XP + + + {entry.position === 1 ? '🥇' : entry.position === 2 ? '🥈' : '🥉'} + + + + ); +} + +// ═══ Segment Groupe ═══════════════════════════════════════════════════════════ + +function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, onCheckStreak }) { + const [selectedIds, setSelectedIds] = useState([]); + const [groupName, setGroupName] = useState(''); + + const toggle = (id) => + setSelectedIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]); + + return ( + <> + {/* ── Invitations reçues ── */} + {invites.map((inv) => ( + + + 🔥 Demande de Streak de Groupe — {inv.name || 'Sans nom'} + + + onRespond(inv._id, true)} /> + onRespond(inv._id, false)} /> + + + ))} + + {group ? ( + + ) : ( + <> + CRÉER UN GROUPE DE STREAK (MAX 5) + + + + + + {friends.length === 0 ? ( + Ajoute d'abord des amis pour former un groupe. + ) : ( + <> + {friends.map((f) => { + const selected = selectedIds.includes(f.user._id); + return ( + toggle(f.user._id)} + activeOpacity={0.8} + > + + {f.user.pseudo} + Nv. {f.user.level} + + ); + })} + 4) && { opacity: 0.4 }]} + disabled={selectedIds.length === 0 || selectedIds.length > 4} + onPress={() => { onInvite(selectedIds, groupName.trim() || undefined); setSelectedIds([]); setGroupName(''); }} + activeOpacity={0.85} + > + + + Envoyer la demande ({selectedIds.length + 1}/5 membres) + + + + )} + + )} + + ); +} + +function GroupCard({ group, onShake, onCheckStreak }) { + const flame = useRef(new Animated.Value(1)).current; + + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(flame, { toValue: 1.18, duration: 700, useNativeDriver: true }), + Animated.timing(flame, { toValue: 1, duration: 700, useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const memberCount = group.members?.length ?? 0; + const multiplier = (1 + 0.25 * (memberCount - 1)).toFixed(2); + + return ( + + + 🔥 + + {group.name || 'Groupe de Streak'} + + Streak : {group.currentStreak ?? 0} jours + {' '}Multiplicateur : x{multiplier} + + + + + MEMBRES ({memberCount}/5) + {(group.members ?? []).map((m) => ( + + onShake(group._id, m._id, m.pseudo)} + activeOpacity={0.8} + > + 🚨 Secouer + + + ))} + + onCheckStreak(group._id)} activeOpacity={0.85}> + + Valider la streak du jour + + + ); +} + +// ═══ Composants partagés ══════════════════════════════════════════════════════ + +function AnimatedRow({ index, children }) { + const slide = useRef(new Animated.Value(18)).current; + const fade = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(slide, { toValue: 0, duration: 280, delay: index * 55, useNativeDriver: true }), + Animated.timing(fade, { toValue: 1, duration: 280, delay: index * 55, useNativeDriver: true }), + ]).start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); +} + +function UserRow({ user, children }) { + return ( + + + {(user?.pseudo ?? '?').charAt(0).toUpperCase()} + + + {user?.pseudo ?? '—'} + Nv. {user?.level ?? 1} · {user?.rank ?? 'Novice'} + + {children} + + ); +} + +function FriendshipHearts({ level }) { + return ( + + {'❤️'.repeat(level)}{'🤍'.repeat(Math.max(0, 5 - level))} + + ); +} + +function SmallBtn({ label, icon, color, onPress }) { + return ( + + + {label ? {label} : null} + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: Colors.bgAbyss }, + title: { + color: Colors.textPrimary, fontSize: 26, fontWeight: '800', + letterSpacing: -0.5, paddingHorizontal: 16, paddingTop: 58, paddingBottom: 14, + }, + loadingBox: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + content: { paddingHorizontal: 16 }, + + // ── Segments ── + segmentRow: { + flexDirection: 'row', gap: 8, paddingHorizontal: 16, marginBottom: 14, + }, + segmentBtn: { + flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + gap: 5, height: 38, borderRadius: 11, + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', + }, + segmentBtnActive: { backgroundColor: Colors.primary, borderColor: Colors.primary }, + segmentTxt: { color: Colors.textMuted, fontSize: 12.5, fontWeight: '700' }, + segmentTxtActive: { color: '#fff' }, + badge: { + backgroundColor: Colors.error, borderRadius: 9, minWidth: 17, height: 17, + justifyContent: 'center', alignItems: 'center', paddingHorizontal: 4, + }, + badgeTxt: { color: '#fff', fontSize: 10, fontWeight: '800' }, + + sectionLabel: { + color: Colors.textMuted, fontSize: 11, fontWeight: '700', + letterSpacing: 0.8, marginTop: 20, marginBottom: 8, marginLeft: 4, + }, + + // ── Recherche ── + searchBox: { + flexDirection: 'row', alignItems: 'center', gap: 8, + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', + borderRadius: 12, paddingHorizontal: 12, height: 44, + }, + searchInput: { flex: 1, color: Colors.textPrimary, fontSize: 14 }, + + // ── Lignes utilisateur ── + userRow: { + flexDirection: 'row', alignItems: 'center', + backgroundColor: Colors.cardDeep, + borderWidth: 1, borderColor: Colors.borderSubtle, + borderRadius: 14, padding: 12, marginBottom: 8, + }, + avatar: { + width: 40, height: 40, borderRadius: 12, + backgroundColor: 'rgba(254,116,57,0.14)', + justifyContent: 'center', alignItems: 'center', marginRight: 11, + }, + avatarTxt: { color: Colors.primary, fontSize: 16, fontWeight: '800' }, + userPseudo: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' }, + userMeta: { color: Colors.textMuted, fontSize: 11.5, marginTop: 1 }, + userActions:{ flexDirection: 'row', alignItems: 'center', gap: 6 }, + pendingTag: { color: Colors.textMuted, fontSize: 12, fontWeight: '600' }, + friendTag: { color: Colors.valid, fontSize: 12, fontWeight: '700' }, + hearts: { fontSize: 10, letterSpacing: 1 }, + + smallBtn: { + flexDirection: 'row', alignItems: 'center', gap: 4, + borderWidth: 1, borderRadius: 9, paddingHorizontal: 10, height: 32, + }, + smallBtnTxt: { fontSize: 12, fontWeight: '700' }, + + // ── Podium ── + podiumRow: { + flexDirection: 'row', alignItems: 'flex-end', gap: 10, + marginTop: 18, marginBottom: 6, paddingHorizontal: 8, + }, + podiumCol: { flex: 1, alignItems: 'center' }, + podiumPseudo: { color: Colors.textPrimary, fontSize: 12.5, fontWeight: '700', marginBottom: 2 }, + podiumXp: { fontSize: 11, fontWeight: '800', marginBottom: 6 }, + podiumBar: { + width: '100%', borderRadius: 12, borderWidth: 1, + justifyContent: 'flex-start', alignItems: 'center', paddingTop: 8, + }, + podiumMedal: { fontSize: 22 }, + + boardRow: { + flexDirection: 'row', alignItems: 'center', + backgroundColor: Colors.cardDeep, + borderWidth: 1, borderColor: Colors.borderSubtle, + borderRadius: 12, paddingHorizontal: 14, height: 46, marginBottom: 7, + }, + boardRowMe: { borderColor: 'rgba(254,116,57,0.45)', backgroundColor: 'rgba(254,116,57,0.06)' }, + boardPos: { color: Colors.textMuted, fontSize: 13, fontWeight: '800', width: 36 }, + boardPseudo: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' }, + boardXp: { color: Colors.textSecondary, fontSize: 12.5, fontWeight: '700' }, + + // ── Groupe ── + inviteCard: { + backgroundColor: 'rgba(254,116,57,0.07)', + borderWidth: 1, borderColor: 'rgba(254,116,57,0.30)', + borderRadius: 14, padding: 14, marginBottom: 10, + }, + inviteTxt: { color: Colors.textPrimary, fontSize: 13.5, lineHeight: 19, marginBottom: 10 }, + inviteBtns: { flexDirection: 'row', gap: 8 }, + + groupCard: { + backgroundColor: Colors.cardDeep, + borderWidth: 1, borderColor: Colors.borderSubtle, + borderRadius: 18, padding: 16, marginTop: 6, + }, + groupHeader: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + groupFlame: { fontSize: 34 }, + groupName: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' }, + groupStreak: { color: Colors.textSecondary, fontSize: 12.5, marginTop: 3 }, + + shakeBtn: { + backgroundColor: 'rgba(255,77,77,0.10)', + borderWidth: 1, borderColor: 'rgba(255,77,77,0.40)', + borderRadius: 9, paddingHorizontal: 10, height: 32, justifyContent: 'center', + }, + shakeTxt: { color: Colors.error, fontSize: 11.5, fontWeight: '800' }, + + selectRow: { + flexDirection: 'row', alignItems: 'center', gap: 10, + backgroundColor: Colors.cardDeep, + borderWidth: 1, borderColor: Colors.borderSubtle, + borderRadius: 12, padding: 12, marginBottom: 7, + }, + selectRowActive: { borderColor: 'rgba(254,116,57,0.45)' }, + selectPseudo: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' }, + selectLevel: { color: Colors.textMuted, fontSize: 12 }, + + ctaBtn: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + backgroundColor: Colors.primary, borderRadius: 13, height: 48, marginTop: 14, + }, + ctaTxt: { color: '#fff', fontSize: 14, fontWeight: '800' }, + + // ── Vide ── + emptyBox: { alignItems: 'center', paddingVertical: 36 }, + emptyEmoji: { fontSize: 38, marginBottom: 10 }, + emptyTxt: { color: Colors.textMuted, fontSize: 13, lineHeight: 20, textAlign: 'center' }, + emptySmall: { color: Colors.textMuted, fontSize: 12.5, marginTop: 8, marginLeft: 4 }, +}); diff --git a/front/src/services/inventory.service.js b/front/src/services/inventory.service.js new file mode 100644 index 0000000..9301980 --- /dev/null +++ b/front/src/services/inventory.service.js @@ -0,0 +1,63 @@ +import API from '../api/api'; + +// ─── Inventaire & Coffres (Brique II) ───────────────────────────────────────── + +// Catalogue d'affichage — miroir du backend (User.js enum + chest.service.js). +// Source de vérité des effets : le backend. Ceci ne sert qu'à l'UI. +export const ITEM_CATALOG = { + ENERGY_DRINK: { + name: 'Boisson Énergisante', emoji: '🥤', rarity: 'common', + description: '+150 XP instantané', usable: true, + }, + STREAK_FREEZE: { + name: 'Gel de Streak', emoji: '🧊', rarity: 'rare', + description: 'Charge 1 gel — sauve ta streak en cas de jour manqué', usable: true, + }, + DOUBLE_XP: { + name: 'Boost Double XP', emoji: '⚡', rarity: 'rare', + description: 'Bonus Double XP sur tes séances', usable: true, + }, + SUPER_STREAK_FREEZE: { + name: 'Super-Gel de Streak', emoji: '❄️', rarity: 'epic', + description: 'Recharge instantanément tes 3 slots de gels', usable: true, + }, + TRIPLE_XP: { + name: 'Boost Triple XP', emoji: '🔥', rarity: 'epic', + description: 'Bonus Triple XP sur tes séances', usable: true, + }, + LEVEL_COUPON: { + name: 'Coupon de Niveau', emoji: '🎫', rarity: 'legendary', + description: 'Passe instantanément au niveau supérieur', usable: true, + }, + QUINTUPLE_XP: { + name: 'Boost Quintuple XP', emoji: '💥', rarity: 'legendary', + description: 'Bonus Quintuple XP sur tes séances', usable: true, + }, + CHEST_KEY: { + name: 'Coffre', emoji: '📦', rarity: 'common', + description: 'Un coffre à ouvrir — que contient-il ?', usable: false, + }, + PROFILE_FRAME_BLOOD_BOND: { + name: 'Cadre "Lien de Sang"', emoji: '🩸', rarity: 'unique', + description: "Cosmétique Unique — niveau d'amitié 5. Introuvable en coffre.", usable: false, + }, +}; + +// Couleurs par rareté — alignées sur la palette du jeu (theme.js) +export const RARITY_META = { + common: { label: 'Commun', color: '#9AA0AE' }, + rare: { label: 'Rare', color: '#3B82F6' }, + epic: { label: 'Épique', color: '#A855F7' }, + legendary: { label: 'Légendaire', color: '#FE7439' }, + unique: { label: 'Unique', color: '#FFD700' }, +}; + +export async function openChest() { + const res = await API.post('/inventory/chest/open'); + return res.data; +} + +export async function useItem(itemType) { + const res = await API.post('/inventory/item/use', { itemType }); + return res.data; +} diff --git a/front/src/services/social.service.js b/front/src/services/social.service.js new file mode 100644 index 0000000..2fae3c6 --- /dev/null +++ b/front/src/services/social.service.js @@ -0,0 +1,77 @@ +import API from '../api/api'; + +// ─── Amis (Brique III) ──────────────────────────────────────────────────────── + +export async function searchUsers(query) { + const res = await API.get(`/friends/search?q=${encodeURIComponent(query)}`); + return res.data; +} + +export async function sendFriendRequest(friendId) { + const res = await API.post('/friends/request', { friendId }); + return res.data; +} + +export async function acceptFriendRequest(requestId) { + const res = await API.put(`/friends/accept/${requestId}`); + return res.data; +} + +export async function declineFriendRequest(requestId) { + const res = await API.put(`/friends/decline/${requestId}`); + return res.data; +} + +export async function getFriendsList() { + const res = await API.get('/friends/list'); + return res.data; +} + +export async function getPendingRequests() { + const res = await API.get('/friends/pending'); + return res.data; +} + +export async function getFriendProfile(friendId) { + const res = await API.get(`/friends/profile/${friendId}`); + return res.data; +} + +export async function getLeaderboard() { + const res = await API.get('/friends/leaderboard'); + return res.data; +} + +// ─── Groupes de Streak (Brique IV) ──────────────────────────────────────────── + +export async function getMyGroup() { + const res = await API.get('/groups/my-group'); + return res.data; +} + +export async function inviteToGroup(friendIds, name) { + const res = await API.post('/groups/invite', { friendIds, name }); + return res.data; +} + +export async function respondToGroupInvite(groupId, accept) { + const res = await API.put(`/groups/respond/${groupId}`, { accept }); + return res.data; +} + +export async function shakeMember(groupId, memberId) { + const res = await API.post(`/groups/${groupId}/shake/${memberId}`); + return res.data; +} + +export async function checkGroupStreak(groupId) { + const res = await API.post(`/groups/${groupId}/check-streak`); + return res.data; +} + +// ─── Parrainage (Brique III) ────────────────────────────────────────────────── + +export async function claimReferral(code) { + const res = await API.post('/referral/claim', { code }); + return res.data; +} From fa0aa9fe98243f205c1429e34547cc2ab8e024e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 14:53:05 +0200 Subject: [PATCH 07/31] fix: corrections de linting sur l'integration du moteur social et de l'inventaire --- back/app.js | 2 + back/controllers/debug.controller.js | 54 +++++++++ back/controllers/groupStreak.controller.js | 7 +- back/controllers/inventory.controller.js | 20 +-- back/middleware/devOnly.middleware.js | 16 +++ back/routes/debug.routes.js | 15 +++ back/tests/debug.test.js | 114 ++++++++++++++++++ back/utils/levelHelpers.js | 23 +++- .../components/inventory/ChestOpeningModal.js | 18 ++- front/src/screens/Profile/InventoryScreen.js | 18 +-- front/src/screens/Profile/ProfileScreen.js | 47 +++++++- .../src/screens/Profile/RankRoadmapScreen.js | 1 + front/src/screens/Profile/SettingsScreen.js | 38 +++++- front/src/services/debug.service.js | 9 ++ front/src/services/inventory.service.js | 19 +-- 15 files changed, 345 insertions(+), 56 deletions(-) create mode 100644 back/controllers/debug.controller.js create mode 100644 back/middleware/devOnly.middleware.js create mode 100644 back/routes/debug.routes.js create mode 100644 back/tests/debug.test.js create mode 100644 front/src/services/debug.service.js diff --git a/back/app.js b/back/app.js index eb045cf..a0430d2 100644 --- a/back/app.js +++ b/back/app.js @@ -14,6 +14,7 @@ const inventoryRoutes = require("./routes/inventory.routes"); const groupStreakRoutes = require("./routes/groupStreak.routes"); const rewardRoutes = require("./routes/reward.routes"); const referralRoutes = require("./routes/referral.routes"); +const debugRoutes = require("./routes/debug.routes"); // --- Importation des middlewares --- const errorMiddleware = require("./middleware/error.middleware"); @@ -92,6 +93,7 @@ app.use("/api/inventory", inventoryRoutes); app.use("/api/groups", groupStreakRoutes); app.use("/api/rewards", rewardRoutes); app.use("/api/referral", referralRoutes); +app.use("/api/debug", debugRoutes); // --- Gestion des erreurs --- // Route 404 diff --git a/back/controllers/debug.controller.js b/back/controllers/debug.controller.js new file mode 100644 index 0000000..66ddb30 --- /dev/null +++ b/back/controllers/debug.controller.js @@ -0,0 +1,54 @@ +'use strict'; + +const User = require('../models/User'); +const { xpForLevel, getRankForLevel } = require('../utils/levelHelpers'); + +function createError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +// ───────────────────────────────────────────────────────────────────────────── +// syncLevel POST /api/debug/sync-level +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test réservé au dev/QA (jamais monté en production — voir + * devOnly.middleware.js). Aligne xp/level/rank backend sur un niveau cible. + * + * Contexte : le panneau "God Mode" du front simule XP/niveau/streak + * uniquement en local (AsyncStorage) pour l'affichage — il ne touche jamais + * le `user.level` backend, qui est la SEULE source de vérité pour les + * fonctionnalités gated côté serveur (coffres, niveau 11+). Sans cette + * route, tester ces fonctionnalités nécessiterait des dizaines de vraies + * séances. N'affecte jamais inventory/achievements — uniquement xp/level/rank. + */ +exports.syncLevel = async (req, res, next) => { + try { + const target = parseInt(req.body.level, 10); + if (!Number.isFinite(target) || target < 0 || target > 200) { + return next(createError('level doit être un entier entre 0 et 200.', 400)); + } + + const xp = xpForLevel(target); + const rank = getRankForLevel(target); + + const user = await User.findByIdAndUpdate( + req.user.id, + { $set: { level: target, xp, rank } }, + { new: true }, + ).select('level xp rank'); + + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + return res.status(200).json({ + success: true, + level: user.level, + xp: user.xp, + rank: user.rank, + }); + } catch (err) { + next(err); + } +}; diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index 8e55177..9e8b78b 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -7,7 +7,7 @@ const User = require('../models/User'); const Workout = require('../models/Workout'); const { addUniqueItemOnce } = require('../services/inventory.service'); const { checkAndUnlockAchievements } = require('./reward.controller'); -const { levelFromXP } = require('../utils/levelHelpers'); +const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); // ─── Constantes ─────────────────────────────────────────────────────────────── @@ -113,7 +113,10 @@ async function grantGroupXpBonus(memberId, bonusXp) { if (!updated) return; const newLevel = levelFromXP(updated.xp); if (newLevel !== updated.level) { - await User.updateOne({ _id: memberId }, { $set: { level: newLevel } }); + await User.updateOne( + { _id: memberId }, + { $set: { level: newLevel, rank: getRankForLevel(newLevel) } }, + ); } } diff --git a/back/controllers/inventory.controller.js b/back/controllers/inventory.controller.js index e9c3cf4..f531500 100644 --- a/back/controllers/inventory.controller.js +++ b/back/controllers/inventory.controller.js @@ -3,6 +3,7 @@ const User = require('../models/User'); const { drawChestItem } = require('../services/chest.service'); const { consumeItemAtomic, addItemAtomic, purgeEmptyEntries } = require('../services/inventory.service'); +const { getRankForLevel } = require('../utils/levelHelpers'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -14,25 +15,6 @@ function createError(message, statusCode = 400) { const MIN_LEVEL_FOR_CHEST = 11; -// Correspondance niveau → rang (ordre décroissant : premier match gagné) -const RANK_THRESHOLDS = [ - { min: 200, rank: 'ATHLY GOD' }, - { min: 171, rank: 'Légende' }, - { min: 141, rank: 'Grand Maître' }, - { min: 111, rank: 'Maître' }, - { min: 91, rank: 'Élite' }, - { min: 71, rank: 'Warrior' }, - { min: 51, rank: 'Compétiteur' }, - { min: 31, rank: 'Athlète' }, - { min: 11, rank: 'Initié' }, - { min: 1, rank: 'Novice' }, -]; - -function getRankForLevel(level) { - const match = RANK_THRESHOLDS.find((t) => level >= t.min); - return match ? match.rank : 'Novice'; -} - // Effets des consommables — chaque fonction modifie user en place // Note : DOUBLE/TRIPLE/QUINTUPLE_XP donnent un XP instantané. // Un système de boost temporaire (multiplicateur) est prévu dans une brique future. diff --git a/back/middleware/devOnly.middleware.js b/back/middleware/devOnly.middleware.js new file mode 100644 index 0000000..35722c4 --- /dev/null +++ b/back/middleware/devOnly.middleware.js @@ -0,0 +1,16 @@ +'use strict'; + +const config = require('../config/env'); + +/** + * Bloque l'accès à une route en production (404, pour ne pas même révéler + * que la route existe). Utilisé pour les endpoints d'outillage de test + * (ex: synchronisation God Mode) qui ne doivent jamais être exposés + * en dehors du développement/QA. + */ +module.exports = function devOnly(req, res, next) { + if (config.nodeEnv === 'production') { + return res.status(404).json({ success: false, message: 'Route introuvable.' }); + } + next(); +}; diff --git a/back/routes/debug.routes.js b/back/routes/debug.routes.js new file mode 100644 index 0000000..7eb917b --- /dev/null +++ b/back/routes/debug.routes.js @@ -0,0 +1,15 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth.middleware'); +const devOnly = require('../middleware/devOnly.middleware'); +const debug = require('../controllers/debug.controller'); + +// devOnly AVANT auth : en production, la route 404 sans même vérifier le token. +router.use(devOnly); +router.use(auth); + +router.post('/sync-level', debug.syncLevel); + +module.exports = router; diff --git a/back/tests/debug.test.js b/back/tests/debug.test.js new file mode 100644 index 0000000..9ea00ef --- /dev/null +++ b/back/tests/debug.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const { xpForLevel } = require('../utils/levelHelpers'); + +async function createAndLoginUser(pseudo, email) { + await request(app) + .post('/api/auth/register') + .send({ pseudo, email, password: 'Password123!' }); + + await User.updateOne({ email }, { isVerified: true }); + + const loginRes = await request(app) + .post('/api/auth/login') + .send({ email, password: 'Password123!' }); + + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +describe('POST /api/debug/sync-level — outil dev (God Mode → backend)', () => { + let alice; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + alice = await createAndLoginUser('AliceFit', 'alice@athly.fr'); + }); + + it('✅ Aligne xp/level/rank backend sur le niveau cible', async () => { + const res = await request(app) + .post('/api/debug/sync-level') + .set('Authorization', `Bearer ${alice.token}`) + .send({ level: 12 }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(12); + expect(res.body.rank).toBe('Initié'); + expect(res.body.xp).toBe(xpForLevel(12)); + + const user = await User.findById(alice.userId); + expect(user.level).toBe(12); + expect(user.rank).toBe('Initié'); + }); + + it('✅ Débloque immédiatement les coffres (gate niveau 11) après sync', async () => { + await request(app) + .post('/api/debug/sync-level') + .set('Authorization', `Bearer ${alice.token}`) + .send({ level: 12 }); + + await User.updateOne( + { _id: alice.userId }, + { inventory: [{ itemType: 'CHEST_KEY', rarity: 'common', quantity: 1 }] }, + ); + + const res = await request(app) + .post('/api/inventory/chest/open') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + }); + + it('❌ 400 si level est hors bornes (0-200) ou invalide', async () => { + const tooHigh = await request(app) + .post('/api/debug/sync-level') + .set('Authorization', `Bearer ${alice.token}`) + .send({ level: 500 }); + expect(tooHigh.statusCode).toBe(400); + + const invalid = await request(app) + .post('/api/debug/sync-level') + .set('Authorization', `Bearer ${alice.token}`) + .send({ level: 'abc' }); + expect(invalid.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/sync-level').send({ level: 10 }); + expect(res.statusCode).toBe(401); + }); + + it('🔒 404 en production — la route est invisible (defense in depth)', async () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + const config = require('../config/env'); + const originalNodeEnv = config.nodeEnv; + config.nodeEnv = 'production'; + + try { + const res = await request(app) + .post('/api/debug/sync-level') + .set('Authorization', `Bearer ${alice.token}`) + .send({ level: 10 }); + expect(res.statusCode).toBe(404); + } finally { + config.nodeEnv = originalNodeEnv; + process.env.NODE_ENV = original; + } + }); +}); diff --git a/back/utils/levelHelpers.js b/back/utils/levelHelpers.js index d9bb122..2c25388 100644 --- a/back/utils/levelHelpers.js +++ b/back/utils/levelHelpers.js @@ -30,4 +30,25 @@ function levelFromXP(totalXP) { return level; } -module.exports = { xpForLevel, levelFromXP }; +// Correspondance niveau → rang (ordre décroissant : premier match gagné). +// Source de vérité unique — utilisée partout où le rang doit être recalculé +// après un changement de niveau (coffres, bonus de groupe, coupon, debug). +const RANK_THRESHOLDS = [ + { min: 200, rank: 'ATHLY GOD' }, + { min: 171, rank: 'Légende' }, + { min: 141, rank: 'Grand Maître' }, + { min: 111, rank: 'Maître' }, + { min: 91, rank: 'Élite' }, + { min: 71, rank: 'Warrior' }, + { min: 51, rank: 'Compétiteur' }, + { min: 31, rank: 'Athlète' }, + { min: 11, rank: 'Initié' }, + { min: 1, rank: 'Novice' }, +]; + +function getRankForLevel(level) { + const match = RANK_THRESHOLDS.find((t) => level >= t.min); + return match ? match.rank : 'Novice'; +} + +module.exports = { xpForLevel, levelFromXP, getRankForLevel }; diff --git a/front/src/components/inventory/ChestOpeningModal.js b/front/src/components/inventory/ChestOpeningModal.js index 3ef876f..5b6a244 100644 --- a/front/src/components/inventory/ChestOpeningModal.js +++ b/front/src/components/inventory/ChestOpeningModal.js @@ -1,5 +1,6 @@ import React, { useRef, useEffect, useState } from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated, Easing } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { ITEM_CATALOG, RARITY_META } from '../../services/inventory.service'; import BirthdayConfetti from '../profile/BirthdayConfetti'; @@ -103,13 +104,11 @@ export default function ChestOpeningModal({ visible, drawnItem, onClose }) { {phase !== 'reveal' ? ( - - 📦 - + + ) : ( {/* Halo pulsé à la couleur de la rareté */} @@ -119,8 +118,8 @@ export default function ChestOpeningModal({ visible, drawnItem, onClose }) { opacity: glow.interpolate({ inputRange: [0, 1], outputRange: [0.06, 0.22] }), }]} /> - - {item?.emoji ?? '❔'} + + @@ -152,7 +151,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 32, }, flash: { backgroundColor: '#FFFFFF' }, - chest: { fontSize: 96 }, revealWrap: { alignItems: 'center', width: '100%' }, halo: { @@ -162,7 +160,7 @@ const styles = StyleSheet.create({ height: 260, borderRadius: 130, }, - itemEmoji: { fontSize: 84, marginBottom: 18 }, + itemIconWrap: { marginBottom: 18 }, rarityLabel: { fontSize: 13, fontWeight: '800', diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index 8a523ac..24cdf41 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -104,7 +104,7 @@ export default function InventoryScreen({ navigation }) { MES OBJETS {items.length === 0 ? ( - 🎒 + Ton sac est vide.{'\n'}Enchaîne les séances pour gagner des coffres ! @@ -154,9 +154,9 @@ function ChestCard({ count, locked, busy, onOpen }) { return ( - - {locked ? '🔒' : '📦'} - + + + @@ -213,7 +213,7 @@ function StaggeredItemCard({ entry, index, busy, onUse }) { }]} > - {meta.emoji ?? '❔'} + @@ -274,7 +274,11 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255,255,255,0.04)', borderColor: 'rgba(255,255,255,0.09)', }, - chestEmoji: { fontSize: 40, marginRight: 14 }, + chestIconWrap: { + width: 60, height: 60, borderRadius: 16, + backgroundColor: 'rgba(255,255,255,0.05)', + justifyContent: 'center', alignItems: 'center', marginRight: 14, + }, chestInfo: { flex: 1, marginRight: 10 }, chestTitle: { color: Colors.textPrimary, fontSize: 15, fontWeight: '800', marginBottom: 3 }, chestSub: { color: Colors.textSecondary, fontSize: 12, lineHeight: 17 }, @@ -303,7 +307,6 @@ const styles = StyleSheet.create({ width: 52, height: 52, borderRadius: 14, justifyContent: 'center', alignItems: 'center', marginRight: 12, }, - itemEmoji: { fontSize: 26 }, itemInfo: { flex: 1, marginRight: 10 }, itemNameRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, itemName: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' }, @@ -321,6 +324,5 @@ const styles = StyleSheet.create({ // ── Vide ── emptyBox: { alignItems: 'center', paddingVertical: 40 }, - emptyEmoji: { fontSize: 40, marginBottom: 12 }, emptyTxt: { color: Colors.textMuted, fontSize: 13, lineHeight: 20, textAlign: 'center' }, }); diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js index 9a8367b..8181e31 100644 --- a/front/src/screens/Profile/ProfileScreen.js +++ b/front/src/screens/Profile/ProfileScreen.js @@ -248,14 +248,24 @@ export default function ProfileScreen({ navigation }) { onPress={() => navigation && navigation.navigate('TrophyRoom')} accentColor={isElite ? rank.color : null} /> - navigation && navigation.navigate('Inventory')} - accentColor={isElite ? rank.color : null} - /> + {/* ── Inventaire (bannière pleine largeur — trop à l'étroit dans quickActions) ── */} + navigation && navigation.navigate('Inventory')} + activeOpacity={0.85} + > + + + + + Inventaire + Coffres, objets & récompenses + + + + {/* ── Vitrine de trophées ── */}
navigation && navigation.navigate('TrophyRoom')}> @@ -394,6 +404,31 @@ const styles = StyleSheet.create({ }, quickBtnText: { color: Colors.textSecondary, fontSize: 12, fontWeight: '700' }, + inventoryBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginTop: 10, + backgroundColor: Colors.glassBg, + paddingVertical: 13, + paddingHorizontal: 14, + borderRadius: 14, + borderWidth: 1, + borderColor: Colors.glassBorder, + }, + inventoryIconBox: { + width: 38, + height: 38, + borderRadius: 11, + backgroundColor: 'rgba(254,116,57,0.14)', + borderWidth: 1, + borderColor: 'rgba(254,116,57,0.30)', + justifyContent: 'center', + alignItems: 'center', + }, + inventoryTitle: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' }, + inventorySub: { color: Colors.textMuted, fontSize: 11.5, marginTop: 1 }, + section: { marginTop: 26 }, sectionRow: { flexDirection: 'row', alignItems: 'center', diff --git a/front/src/screens/Profile/RankRoadmapScreen.js b/front/src/screens/Profile/RankRoadmapScreen.js index 3d79128..d03c56d 100644 --- a/front/src/screens/Profile/RankRoadmapScreen.js +++ b/front/src/screens/Profile/RankRoadmapScreen.js @@ -44,6 +44,7 @@ const RANKS = [ perks: [ 'Cadre Bronze débloqué (Niv. 11)', 'Forme Hexagone débloquée (Niv. 11)', + 'Inventaire débloqué : coffres à ouvrir en cumulant tes séances', ], icon: 'ribbon', }, diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 0b7e95d..d72ac5e 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -33,6 +33,7 @@ import { scheduleDailyReminder, cancelDailyReminder, } from '../../services/notificationService'; +import { syncBackendLevel } from '../../services/debug.service'; const UNIT_WEIGHT_KEY = 'athly:unit:weight:v1'; const UNIT_DIST_KEY = 'athly:unit:distance:v1'; @@ -49,7 +50,7 @@ const DEV_TAP_TARGET = 10; export default function SettingsScreen({ navigation }) { const { signOut } = useAuth(); - const { setUser } = useUser(); + const { setUser, refetch: refetchUser } = useUser(); const { showToast } = useToast(); const { totalXP, sessionLogs, activityLogs, refresh, clearAll: clearWorkoutLogs } = useWorkoutLogs(); const { clearAll: clearSavedWorkouts } = useSavedWorkouts(); @@ -293,6 +294,26 @@ export default function SettingsScreen({ navigation }) { showFeedback('Overrides trophées réinitialisés ✓'); }; + // Le niveau simulé ci-dessus (debugSetLevel…) reste 100% local (AsyncStorage) — + // il ne débloque pas les fonctionnalités gated côté serveur (coffres, niveau 11+). + // Ce bouton pousse le niveau affiché vers le user.level backend pour tester + // ces features sans dizaines de vraies séances. Bloqué en production (404). + const handleSyncBackendLevel = useCallback(async () => { + try { + setSimLoading(true); + const res = await syncBackendLevel(level); + await refetchUser(); + showFeedback(`Backend synchronisé : niveau ${res.level} (${res.rank}) ✓`); + } catch (e) { + const msg = e?.status === 404 + ? 'Indisponible en production.' + : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [level, refetchUser, showFeedback]); + const handleLockDevSection = useCallback(async () => { setDevVisible(false); setTapCount(0); @@ -489,6 +510,21 @@ export default function SettingsScreen({ navigation }) { XP requis Niv.{level + 1} : {xpForLevel(level + 1).toLocaleString('fr-FR')} + {/* ── SYNC BACKEND ── */} + + + Le niveau ci-dessus est local uniquement — les coffres et fonctionnalités + serveur (niveau 11+) lisent le niveau backend. Synchronise pour les tester. + + + + + {/* ── SIMULATION ── */} diff --git a/front/src/services/debug.service.js b/front/src/services/debug.service.js new file mode 100644 index 0000000..0553a05 --- /dev/null +++ b/front/src/services/debug.service.js @@ -0,0 +1,9 @@ +import API from '../api/api'; + +// Outil de test God Mode → backend (voir back/controllers/debug.controller.js). +// Aligne xp/level/rank backend sur `level` — jamais exposé en production +// (l'endpoint renvoie 404 côté serveur si NODE_ENV=production). +export async function syncBackendLevel(level) { + const res = await API.post('/debug/sync-level', { level }); + return res.data; +} diff --git a/front/src/services/inventory.service.js b/front/src/services/inventory.service.js index 9301980..97b661e 100644 --- a/front/src/services/inventory.service.js +++ b/front/src/services/inventory.service.js @@ -4,41 +4,42 @@ import API from '../api/api'; // Catalogue d'affichage — miroir du backend (User.js enum + chest.service.js). // Source de vérité des effets : le backend. Ceci ne sert qu'à l'UI. +// icon = nom Ionicons (pas d'emoji — cohérence avec le reste du design system). export const ITEM_CATALOG = { ENERGY_DRINK: { - name: 'Boisson Énergisante', emoji: '🥤', rarity: 'common', + name: 'Boisson Énergisante', icon: 'flash', rarity: 'common', description: '+150 XP instantané', usable: true, }, STREAK_FREEZE: { - name: 'Gel de Streak', emoji: '🧊', rarity: 'rare', + name: 'Gel de Streak', icon: 'snow', rarity: 'rare', description: 'Charge 1 gel — sauve ta streak en cas de jour manqué', usable: true, }, DOUBLE_XP: { - name: 'Boost Double XP', emoji: '⚡', rarity: 'rare', + name: 'Boost Double XP', icon: 'flash-outline', rarity: 'rare', description: 'Bonus Double XP sur tes séances', usable: true, }, SUPER_STREAK_FREEZE: { - name: 'Super-Gel de Streak', emoji: '❄️', rarity: 'epic', + name: 'Super-Gel de Streak', icon: 'snow-outline', rarity: 'epic', description: 'Recharge instantanément tes 3 slots de gels', usable: true, }, TRIPLE_XP: { - name: 'Boost Triple XP', emoji: '🔥', rarity: 'epic', + name: 'Boost Triple XP', icon: 'flame', rarity: 'epic', description: 'Bonus Triple XP sur tes séances', usable: true, }, LEVEL_COUPON: { - name: 'Coupon de Niveau', emoji: '🎫', rarity: 'legendary', + name: 'Coupon de Niveau', icon: 'ticket', rarity: 'legendary', description: 'Passe instantanément au niveau supérieur', usable: true, }, QUINTUPLE_XP: { - name: 'Boost Quintuple XP', emoji: '💥', rarity: 'legendary', + name: 'Boost Quintuple XP', icon: 'rocket', rarity: 'legendary', description: 'Bonus Quintuple XP sur tes séances', usable: true, }, CHEST_KEY: { - name: 'Coffre', emoji: '📦', rarity: 'common', + name: 'Coffre', icon: 'cube', rarity: 'common', description: 'Un coffre à ouvrir — que contient-il ?', usable: false, }, PROFILE_FRAME_BLOOD_BOND: { - name: 'Cadre "Lien de Sang"', emoji: '🩸', rarity: 'unique', + name: 'Cadre "Lien de Sang"', icon: 'shield', rarity: 'unique', description: "Cosmétique Unique — niveau d'amitié 5. Introuvable en coffre.", usable: false, }, }; From d6781e5b6170678e0366e9664ef3c63b9ecf3f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 14:58:08 +0200 Subject: [PATCH 08/31] fix: corrections de linting sur l'integration du moteur social et de l'inventaire --- back/controllers/inventory.controller.js | 87 ------------------------ 1 file changed, 87 deletions(-) diff --git a/back/controllers/inventory.controller.js b/back/controllers/inventory.controller.js index 7c3f596..f531500 100644 --- a/back/controllers/inventory.controller.js +++ b/back/controllers/inventory.controller.js @@ -15,93 +15,6 @@ function createError(message, statusCode = 400) { const MIN_LEVEL_FOR_CHEST = 11; -// Correspondance niveau → rang (ordre décroissant : premier match gagné) -const RANK_THRESHOLDS = [ - { min: 200, rank: 'ATHLY GOD' }, - { min: 171, rank: 'Légende' }, - { min: 141, rank: 'Grand Maître' }, - { min: 111, rank: 'Maître' }, - { min: 91, rank: 'Élite' }, - { min: 71, rank: 'Warrior' }, - { min: 51, rank: 'Compétiteur' }, - { min: 31, rank: 'Athlète' }, - { min: 11, rank: 'Initié' }, - { min: 1, rank: 'Novice' }, -]; - -function getRankForLevel(level) { - const match = RANK_THRESHOLDS.find((t) => level >= t.min); - return match ? match.rank : 'Novice'; -} - -/** - * Consomme atomiquement 1 unité d'un item. - * - * Anti race-condition (double-spend) : le filtre conditionnel garantit que le - * décrément n'a lieu que si la quantité est encore >= 1 AU MOMENT de l'écriture. - * Deux requêtes simultanées sur la dernière unité : une seule matche le filtre, - * l'autre reçoit null — impossible de dépenser deux fois le même objet. - * - * @returns Le document User APRÈS décrément, ou null si non possédé - * (ou si extraFilter ne matche pas). - */ -async function consumeItemAtomic(userId, itemType, extraFilter = {}) { - return User.findOneAndUpdate( - { - _id: userId, - inventory: { $elemMatch: { itemType, quantity: { $gte: 1 } } }, - ...extraFilter, - }, - { $inc: { 'inventory.$[elem].quantity': -1 } }, - { - returnDocument: 'after', - arrayFilters: [{ 'elem.itemType': itemType }], - }, - ); -} - -/** - * Ajoute atomiquement 1 unité d'un item ($inc si l'entrée existe, sinon $push - * gardé par $ne pour éviter un double-push concurrent). - * @returns Le document User APRÈS ajout. - */ -async function addItemAtomic(userId, itemType, rarity) { - const incremented = await User.findOneAndUpdate( - { _id: userId, 'inventory.itemType': itemType }, - { $inc: { 'inventory.$.quantity': 1 } }, - { returnDocument: 'after' }, - ); - if (incremented) return incremented; - - const pushed = await User.findOneAndUpdate( - { _id: userId, 'inventory.itemType': { $ne: itemType } }, - { $push: { inventory: { itemType, rarity, quantity: 1 } } }, - { returnDocument: 'after' }, - ); - if (pushed) return pushed; - - // Course perdue contre un $push concurrent du même itemType : on retombe - // sur le $inc, qui matche forcément maintenant. - return User.findOneAndUpdate( - { _id: userId, 'inventory.itemType': itemType }, - { $inc: { 'inventory.$.quantity': 1 } }, - { returnDocument: 'after' }, - ); -} - -/** - * Purge les entrées d'inventaire tombées à 0 (comportement historique : - * une entrée épuisée disparaît de l'inventaire). - * @returns Le document User APRÈS purge. - */ -async function purgeEmptyEntries(userId) { - return User.findOneAndUpdate( - { _id: userId }, - { $pull: { inventory: { quantity: { $lte: 0 } } } }, - { returnDocument: 'after' }, - ); -} - // Effets des consommables — chaque fonction modifie user en place // Note : DOUBLE/TRIPLE/QUINTUPLE_XP donnent un XP instantané. // Un système de boost temporaire (multiplicateur) est prévu dans une brique future. From c197c50de5a3e5e2edb0c634ac46380c269d9332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 16:19:14 +0200 Subject: [PATCH 09/31] feat: extension du God Mode avec injection de coffres et generateur idempotent de faux amis pour tests sociaux --- back/controllers/debug.controller.js | 120 ++++++++++++- back/routes/debug.routes.js | 6 +- back/tests/debug.test.js | 178 +++++++++++++++++++- front/src/screens/Profile/SettingsScreen.js | 61 ++++++- front/src/services/debug.service.js | 13 ++ 5 files changed, 371 insertions(+), 7 deletions(-) diff --git a/back/controllers/debug.controller.js b/back/controllers/debug.controller.js index 66ddb30..537af63 100644 --- a/back/controllers/debug.controller.js +++ b/back/controllers/debug.controller.js @@ -1,7 +1,13 @@ 'use strict'; -const User = require('../models/User'); +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const User = require('../models/User'); +const Friendship = require('../models/Friendship'); const { xpForLevel, getRankForLevel } = require('../utils/levelHelpers'); +const { addItemAtomic } = require('../services/inventory.service'); + +const MOCK_PASSWORD_ROUNDS = 10; // comptes jetables, jamais utilisés pour se connecter function createError(message, statusCode = 400) { const err = new Error(message); @@ -52,3 +58,115 @@ exports.syncLevel = async (req, res, next) => { next(err); } }; + +// ───────────────────────────────────────────────────────────────────────────── +// giveChests POST /api/debug/godmode/give-chests +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : crédite atomiquement `amount` CHEST_KEY à l'utilisateur + * connecté, pour tester l'ouverture de coffre sans cumuler 5h de séance par + * palier. Réutilise addItemAtomic (même garde anti-race que openChest/useItem). + * + * Body : { amount?: number } — défaut 1, borné à 50 pour rester un outil de + * test (pas un moyen de remplir l'inventaire à l'infini en un clic). + */ +exports.giveChests = async (req, res, next) => { + try { + const amount = req.body.amount === undefined ? 1 : parseInt(req.body.amount, 10); + if (!Number.isFinite(amount) || amount < 1 || amount > 50) { + return next(createError('amount doit être un entier entre 1 et 50.', 400)); + } + + const user = await addItemAtomic(req.user.id, 'CHEST_KEY', 'common', amount); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + const chestEntry = user.inventory.find((i) => i.itemType === 'CHEST_KEY'); + + return res.status(200).json({ + success: true, + message: `+${amount} coffre(s) ajouté(s).`, + chestCount: chestEntry ? chestEntry.quantity : 0, + inventory: user.inventory, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// mockSocial POST /api/debug/godmode/mock-social +// ───────────────────────────────────────────────────────────────────────────── + +// 3 profils types couvrant les usages de l'écran Social : +// - 2 amis "accepted" → alimentent le Classement/Podium et permettent de +// créer un Groupe de Streak. +// - 1 ami "pending" (le faux profil est le requester, l'appelant le +// recipient) → apparaît dans les demandes reçues, pour tester +// Accepter/Refuser. +const MOCK_FRIEND_SPECS = [ + { suffix: 1, status: 'accepted' }, + { suffix: 2, status: 'accepted' }, + { suffix: 3, status: 'pending' }, +]; + +/** + * Outil de test : génère (ou régénère) un petit réseau social factice + * pour l'utilisateur connecté — 3 faux comptes User + leurs Friendship. + * + * Idempotent : les faux amis précédemment générés PAR CET utilisateur + * (namespacés par son ObjectId dans l'email) sont supprimés avant d'en + * recréer de nouveaux, pour permettre de relancer l'outil sans accumuler + * des dizaines de doublons "FauxAmi_*" en base. + */ +exports.mockSocial = async (req, res, next) => { + try { + const myId = req.user.id; + const emailPrefix = `mock-${myId}-`; + + // ── Nettoyage des faux amis précédents de CET utilisateur ──────────────── + const previousMocks = await User.find({ + email: { $regex: `^${emailPrefix}` }, + }).select('_id'); + const previousIds = previousMocks.map((u) => u._id); + if (previousIds.length > 0) { + await Friendship.deleteMany({ + $or: [{ requester: { $in: previousIds } }, { recipient: { $in: previousIds } }], + }); + await User.deleteMany({ _id: { $in: previousIds } }); + } + + // Mot de passe jetable — ces comptes ne sont jamais destinés à se connecter. + const passwordHash = await bcrypt.hash(crypto.randomBytes(24).toString('hex'), MOCK_PASSWORD_ROUNDS); + + const created = []; + for (const spec of MOCK_FRIEND_SPECS) { + const level = Math.floor(Math.random() * 40) + 5; // 5–44 : mixe plusieurs rangs + const mockUser = await User.create({ + pseudo: `FauxAmi_${spec.suffix}`, + email: `${emailPrefix}${spec.suffix}@athly.dev`, + password: passwordHash, + isVerified: true, + level, + xp: xpForLevel(level), + rank: getRankForLevel(level), + }); + + await Friendship.create( + spec.status === 'pending' + ? { requester: mockUser._id, recipient: myId, status: 'pending' } + : { requester: myId, recipient: mockUser._id, status: 'accepted' }, + ); + + created.push({ pseudo: mockUser.pseudo, level, rank: mockUser.rank, status: spec.status }); + } + + return res.status(201).json({ + success: true, + message: `${created.length} faux profils générés (2 amis acceptés, 1 demande en attente).`, + created, + }); + } catch (err) { + next(err); + } +}; diff --git a/back/routes/debug.routes.js b/back/routes/debug.routes.js index 7eb917b..2d44521 100644 --- a/back/routes/debug.routes.js +++ b/back/routes/debug.routes.js @@ -10,6 +10,10 @@ const debug = require('../controllers/debug.controller'); router.use(devOnly); router.use(auth); -router.post('/sync-level', debug.syncLevel); +router.post('/sync-level', debug.syncLevel); + +// ── God Mode : sandbox de test (voir controllers/debug.controller.js) ──────── +router.post('/godmode/give-chests', debug.giveChests); +router.post('/godmode/mock-social', debug.mockSocial); module.exports = router; diff --git a/back/tests/debug.test.js b/back/tests/debug.test.js index 9ea00ef..2337c51 100644 --- a/back/tests/debug.test.js +++ b/back/tests/debug.test.js @@ -1,9 +1,10 @@ 'use strict'; -const request = require('supertest'); -const mongoose = require('mongoose'); -const app = require('../app'); -const User = require('../models/User'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const Friendship = require('../models/Friendship'); const { xpForLevel } = require('../utils/levelHelpers'); async function createAndLoginUser(pseudo, email) { @@ -112,3 +113,172 @@ describe('POST /api/debug/sync-level — outil dev (God Mode → backend)', () = } }); }); + +describe('POST /api/debug/godmode/give-chests — outil dev (crédite des CHEST_KEY)', () => { + let alice; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + alice = await createAndLoginUser('AliceFit', 'alice@athly.fr'); + }); + + it('✅ +1 coffre par défaut, atomique', async () => { + const res = await request(app) + .post('/api/debug/godmode/give-chests') + .set('Authorization', `Bearer ${alice.token}`) + .send({}); + + expect(res.statusCode).toBe(200); + expect(res.body.chestCount).toBe(1); + + const user = await User.findById(alice.userId); + const chest = user.inventory.find((i) => i.itemType === 'CHEST_KEY'); + expect(chest.quantity).toBe(1); + }); + + it('✅ amount personnalisé s\'ajoute à un stock existant', async () => { + await User.updateOne( + { _id: alice.userId }, + { inventory: [{ itemType: 'CHEST_KEY', rarity: 'common', quantity: 2 }] }, + ); + + const res = await request(app) + .post('/api/debug/godmode/give-chests') + .set('Authorization', `Bearer ${alice.token}`) + .send({ amount: 5 }); + + expect(res.statusCode).toBe(200); + expect(res.body.chestCount).toBe(7); + }); + + it('❌ 400 si amount est hors bornes (1-50)', async () => { + const res = await request(app) + .post('/api/debug/godmode/give-chests') + .set('Authorization', `Bearer ${alice.token}`) + .send({ amount: 999 }); + expect(res.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/give-chests').send({}); + expect(res.statusCode).toBe(401); + }); + + it('🔒 404 en production', async () => { + const config = require('../config/env'); + const originalNodeEnv = config.nodeEnv; + config.nodeEnv = 'production'; + try { + const res = await request(app) + .post('/api/debug/godmode/give-chests') + .set('Authorization', `Bearer ${alice.token}`) + .send({}); + expect(res.statusCode).toBe(404); + } finally { + config.nodeEnv = originalNodeEnv; + } + }); +}); + +describe('POST /api/debug/godmode/mock-social — outil dev (génère un faux réseau social)', () => { + let alice; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + alice = await createAndLoginUser('AliceFit', 'alice@athly.fr'); + }); + + it('✅ Génère 2 amis acceptés + 1 demande en attente reçue', async () => { + const res = await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(201); + expect(res.body.created).toHaveLength(3); + + const friendsRes = await request(app) + .get('/api/friends/list') + .set('Authorization', `Bearer ${alice.token}`); + expect(friendsRes.body.friends).toHaveLength(2); + expect(friendsRes.body.friends.every((f) => f.user.pseudo.startsWith('FauxAmi_'))).toBe(true); + + const pendingRes = await request(app) + .get('/api/friends/pending') + .set('Authorization', `Bearer ${alice.token}`); + expect(pendingRes.body.requests).toHaveLength(1); + expect(pendingRes.body.requests[0].requester.pseudo).toMatch(/^FauxAmi_/); + }); + + it('✅ Le classement inclut les amis acceptés (podium testable)', async () => { + await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + + const res = await request(app) + .get('/api/friends/leaderboard') + .set('Authorization', `Bearer ${alice.token}`); + + // Alice + 2 amis acceptés (le 3e est encore "pending", pas dans le classement) + expect(res.body.count).toBe(3); + }); + + it('🔁 Idempotent : rejouer l\'outil ne crée pas de doublons', async () => { + await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + + const allMocks = await User.find({ pseudo: /^FauxAmi_/ }); + expect(allMocks).toHaveLength(3); + + const friendsRes = await request(app) + .get('/api/friends/list') + .set('Authorization', `Bearer ${alice.token}`); + expect(friendsRes.body.friends).toHaveLength(2); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/mock-social'); + expect(res.statusCode).toBe(401); + }); + + it('🔒 404 en production', async () => { + const config = require('../config/env'); + const originalNodeEnv = config.nodeEnv; + config.nodeEnv = 'production'; + try { + const res = await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(404); + } finally { + config.nodeEnv = originalNodeEnv; + } + }); +}); diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index d72ac5e..8c6994f 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -33,7 +33,7 @@ import { scheduleDailyReminder, cancelDailyReminder, } from '../../services/notificationService'; -import { syncBackendLevel } from '../../services/debug.service'; +import { syncBackendLevel, giveChests, generateMockSocial } from '../../services/debug.service'; const UNIT_WEIGHT_KEY = 'athly:unit:weight:v1'; const UNIT_DIST_KEY = 'athly:unit:distance:v1'; @@ -146,6 +146,7 @@ export default function SettingsScreen({ navigation }) { const [targetLevel, setTargetLevel] = useState(''); const [targetStreak, setTargetStreak] = useState(''); const [targetXP, setTargetXP] = useState(''); + const [targetChests, setTargetChests] = useState('1'); const [simLoading, setSimLoading] = useState(false); const [simFeedback, setSimFeedback] = useState(''); const [trophyExpanded, setTrophyExpanded] = useState(false); @@ -314,6 +315,39 @@ export default function SettingsScreen({ navigation }) { } }, [level, refetchUser, showFeedback]); + // Crédite des CHEST_KEY backend pour tester l'ouverture de coffre sans + // attendre les paliers de 5h de séance. Bloqué en production (404). + const handleGiveChests = useCallback(async () => { + const n = parseInt(targetChests, 10); + if (!targetChests || isNaN(n) || n < 1 || n > 50) { showFeedback('Quantité invalide (1–50)'); return; } + try { + setSimLoading(true); + const res = await giveChests(n); + showFeedback(`+${n} coffre(s) ✓ (total : ${res.chestCount})`); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [targetChests, showFeedback]); + + // Génère 2 amis acceptés + 1 demande en attente pour tester l'écran Social + // (Classement, création de Groupe, Accepter/Refuser) sans dépendre de vrais + // comptes tiers. Idempotent côté backend. Bloqué en production (404). + const handleMockSocial = useCallback(async () => { + try { + setSimLoading(true); + const res = await generateMockSocial(); + showFeedback(res.message || 'Réseau social de test généré ✓'); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback]); + const handleLockDevSection = useCallback(async () => { setDevVisible(false); setTapCount(0); @@ -525,6 +559,31 @@ export default function SettingsScreen({ navigation }) { /> + {/* ── SANDBOX INVENTAIRE & SOCIAL ── */} + + + Coffres et faux amis générés directement en base — pour tester + l'Inventaire et l'écran Social sans dizaines de vraies actions. + + + + + + + + + + Crée FauxAmi_1 et FauxAmi_2 (amis acceptés — pour Classement et Groupe) + + FauxAmi_3 (demande en attente — pour Accepter/Refuser). Rejouable sans doublons. + + {/* ── SIMULATION ── */} diff --git a/front/src/services/debug.service.js b/front/src/services/debug.service.js index 0553a05..38f96d9 100644 --- a/front/src/services/debug.service.js +++ b/front/src/services/debug.service.js @@ -7,3 +7,16 @@ export async function syncBackendLevel(level) { const res = await API.post('/debug/sync-level', { level }); return res.data; } + +// Crédite `amount` CHEST_KEY dans l'inventaire backend (défaut 1). +export async function giveChests(amount = 1) { + const res = await API.post('/debug/godmode/give-chests', { amount }); + return res.data; +} + +// Génère (ou régénère) un faux réseau social : 2 amis acceptés + 1 demande +// en attente reçue, pour tester Classement/Groupe/Accepter-Refuser. +export async function generateMockSocial() { + const res = await API.post('/debug/godmode/mock-social'); + return res.data; +} From 37e1c1d8b25f5628f12bd50eb0a8089a8335358e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 17:17:57 +0200 Subject: [PATCH 10/31] fix: correction de la boisson energisante, globalisation de l'animation de level-up, remplacement des emojis par des icones et ajout de l'option quitter le groupe --- back/controllers/groupStreak.controller.js | 91 ++++++++- back/controllers/inventory.controller.js | 82 ++++++-- back/routes/groupStreak.routes.js | 1 + back/tests/inventory.test.js | 48 +++++ back/tests/socialEngine.test.js | 110 +++++++++- front/src/components/common/ConfirmModal.js | 117 +++++++++++ .../components/profile/LevelUpCelebration.js | 48 +++++ front/src/components/profile/LevelUpModal.js | 148 ++++++++++++++ front/src/context/WorkoutLogsContext.js | 25 ++- front/src/navigation/index.js | 2 + front/src/screens/Profile/InventoryScreen.js | 19 ++ front/src/screens/Profile/SettingsScreen.js | 14 +- .../src/screens/Social/FriendProfileScreen.js | 18 +- front/src/screens/Social/SocialScreen.js | 190 ++++++++++++++++-- front/src/services/inventory.service.js | 6 +- front/src/services/social.service.js | 5 + front/src/services/stats.service.js | 26 +++ 17 files changed, 875 insertions(+), 75 deletions(-) create mode 100644 front/src/components/common/ConfirmModal.js create mode 100644 front/src/components/profile/LevelUpCelebration.js create mode 100644 front/src/components/profile/LevelUpModal.js diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index 9e8b78b..450f948 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -94,13 +94,32 @@ async function addFriendshipXp(userId1, userId2, xpGain) { } // ── Bonus XP de groupe (Brique IV) ──────────────────────────────────────────── -// Multiplicateur croissant avec la taille du groupe : x1.25 par membre -// au-delà du premier (2 → x1.25, 5 → x2.0), appliqué à un bonus de base. -const GROUP_BASE_BONUS_XP = 40; +// Le multiplicateur cumule deux composantes, exposées séparément pour +// affichage front (ex: "x1.98 = x1.35 taille × x1.47 régularité") : +// - Taille : +35% par membre au-delà du premier (2 → x1.35, 5 → x2.40). +// Un groupe à 5 est bien plus dur à maintenir qu'à 2 (aléas du quotidien +// de 5 personnes) : le bonus doit le refléter, pas juste suivre linéairement. +// - Régularité : +8% par semaine de streak consécutive, plafonné à +60% +// (7j → x1.08, ~53j+ → x1.60) — récompense la constance sans devenir infini. +const GROUP_BASE_BONUS_XP = 50; +const REGULARITY_STEP = 0.08; +const REGULARITY_MAX_BONUS = 0.6; + +function round2(n) { + return Math.round(n * 100) / 100; +} -function computeGroupXpBonus(memberCount) { - const multiplier = 1 + 0.25 * (memberCount - 1); - return { multiplier, bonusXp: Math.round(GROUP_BASE_BONUS_XP * multiplier) }; +function computeGroupXpBonus(memberCount, currentStreak = 0) { + const sizeMultiplier = 1 + 0.35 * (memberCount - 1); + const regularityMultiplier = 1 + Math.min(REGULARITY_MAX_BONUS, REGULARITY_STEP * Math.floor(currentStreak / 7)); + const multiplier = sizeMultiplier * regularityMultiplier; + + return { + sizeMultiplier: round2(sizeMultiplier), + regularityMultiplier: round2(regularityMultiplier), + multiplier: round2(multiplier), + bonusXp: Math.round(GROUP_BASE_BONUS_XP * multiplier), + }; } /** Crédite atomiquement le bonus XP à un membre et recale son niveau/rang. */ @@ -440,9 +459,9 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { } } - // Bonus XP utilisateur : multiplicateur croissant avec la taille du groupe - const { multiplier, bonusXp } = computeGroupXpBonus(memberIds.length); - await Promise.all(memberIds.map((id) => grantGroupXpBonus(id, bonusXp))); + // Bonus XP utilisateur : multiplicateur taille × régularité (streak fraîchement incrémenté) + const xpBonus = computeGroupXpBonus(memberIds.length, group.currentStreak); + await Promise.all(memberIds.map((id) => grantGroupXpBonus(id, xpBonus.bonusXp))); return res.status(200).json({ success: true, @@ -451,7 +470,7 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { currentStreak: group.currentStreak, xpGain, xpUpdates, - groupBonus: { multiplier, bonusXp, memberCount: memberIds.length }, + groupBonus: { ...xpBonus, memberCount: memberIds.length }, }); } catch (err) { next(err); @@ -490,7 +509,57 @@ exports.getMyGroup = async (req, res, next) => { }); } - return res.status(200).json({ success: true, group, invites }); + // Multiplicateur courant (taille + régularité), affichable même sans + // attendre la prochaine validation — group est un document Mongoose, + // on construit la réponse à part pour ne pas le muter. + const xpBonus = computeGroupXpBonus(group.members.length, group.currentStreak); + + return res.status(200).json({ + success: true, + group: { ...group.toObject(), xpBonus }, + invites, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// leaveGroup POST /api/groups/leave +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Quitte le groupe de streak actuel. + * + * Si l'utilisateur était le dernier membre, le groupe est dissous + * (supprimé) plutôt que laissé vide en base. Sinon, la streak et + * l'historique du groupe sont conservés pour les membres restants. + */ +exports.leaveGroup = async (req, res, next) => { + try { + const myId = req.user.id; + + const group = await StreakGroup.findOne({ members: myId }); + if (!group) return next(createError("Vous ne faites partie d'aucun groupe.", 404)); + + group.members = group.members.filter((m) => m.toString() !== myId); + + if (group.members.length === 0) { + await StreakGroup.deleteOne({ _id: group._id }); + return res.status(200).json({ + success: true, + message: 'Vous avez quitté le groupe. Il était vide, il a été dissous.', + groupDeleted: true, + }); + } + + await group.save(); + + return res.status(200).json({ + success: true, + message: 'Vous avez quitté le groupe.', + groupDeleted: false, + }); } catch (err) { next(err); } diff --git a/back/controllers/inventory.controller.js b/back/controllers/inventory.controller.js index f531500..0076539 100644 --- a/back/controllers/inventory.controller.js +++ b/back/controllers/inventory.controller.js @@ -3,7 +3,7 @@ const User = require('../models/User'); const { drawChestItem } = require('../services/chest.service'); const { consumeItemAtomic, addItemAtomic, purgeEmptyEntries } = require('../services/inventory.service'); -const { getRankForLevel } = require('../utils/levelHelpers'); +const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -15,19 +15,64 @@ function createError(message, statusCode = 400) { const MIN_LEVEL_FOR_CHEST = 11; -// Effets des consommables — chaque fonction modifie user en place -// Note : DOUBLE/TRIPLE/QUINTUPLE_XP donnent un XP instantané. -// Un système de boost temporaire (multiplicateur) est prévu dans une brique future. +/** + * Crédite atomiquement `amount` XP et recalcule level/rank si un palier est + * franchi. Deux écritures ($inc puis $set conditionnel) mais aucune lecture + * intermédiaire mutable : pas de fenêtre de perte d'XP entre deux consommations + * simultanées (contrairement à un read → mutate en mémoire → save()). + */ +async function applyXpGain(userId, amount) { + const updated = await User.findOneAndUpdate( + { _id: userId }, + { $inc: { xp: amount } }, + { returnDocument: 'after' }, + ); + if (!updated) return null; + + const newLevel = levelFromXP(updated.xp); + if (newLevel === updated.level) return updated; + + return User.findOneAndUpdate( + { _id: userId }, + { $set: { level: newLevel, rank: getRankForLevel(newLevel) } }, + { returnDocument: 'after' }, + ); +} + +// Effets des consommables — chaque effet est une opération atomique côté DB, +// jamais un mutate-en-mémoire + save() (qui perdrait des écritures concurrentes +// sur xp/streakGels si deux items sont utilisés au même instant). const ITEM_EFFECTS = { - ENERGY_DRINK: (user) => { user.xp += 150; }, - STREAK_FREEZE: (user) => { user.streakGels = Math.min(user.streakGels + 1, 3); }, - SUPER_STREAK_FREEZE: (user) => { user.streakGels = 3; }, - DOUBLE_XP: (user) => { user.xp += 200; }, - TRIPLE_XP: (user) => { user.xp += 300; }, - QUINTUPLE_XP: (user) => { user.xp += 500; }, - LEVEL_COUPON: (user) => { - user.level += 1; - user.rank = getRankForLevel(user.level); + ENERGY_DRINK: (userId) => applyXpGain(userId, 150), + DOUBLE_XP: (userId) => applyXpGain(userId, 200), + TRIPLE_XP: (userId) => applyXpGain(userId, 300), + QUINTUPLE_XP: (userId) => applyXpGain(userId, 500), + + // Pipeline d'agrégation dans l'update : le plafond à 3 est calculé côté + // MongoDB en une seule écriture atomique (pas de read-then-clamp en JS). + STREAK_FREEZE: (userId) => User.findOneAndUpdate( + { _id: userId }, + [{ $set: { streakGels: { $min: [{ $add: ['$streakGels', 1] }, 3] } } }], + { returnDocument: 'after', updatePipeline: true }, + ), + SUPER_STREAK_FREEZE: (userId) => User.findOneAndUpdate( + { _id: userId }, + { $set: { streakGels: 3 } }, + { returnDocument: 'after' }, + ), + + LEVEL_COUPON: async (userId) => { + const updated = await User.findOneAndUpdate( + { _id: userId }, + { $inc: { level: 1 } }, + { returnDocument: 'after' }, + ); + if (!updated) return null; + return User.findOneAndUpdate( + { _id: userId }, + { $set: { rank: getRankForLevel(updated.level) } }, + { returnDocument: 'after' }, + ); }, }; @@ -108,18 +153,17 @@ exports.useItem = async (req, res, next) => { } // Consommation atomique : même garde anti double-spend que openChest. - const user = await consumeItemAtomic(req.user.id, itemType); + const consumed = await consumeItemAtomic(req.user.id, itemType); - if (!user) { + if (!consumed) { const exists = await User.exists({ _id: req.user.id }); if (!exists) return next(createError('Utilisateur introuvable.', 404)); return next(createError('Vous ne possédez pas cet objet.', 400)); } - // L'effet ne touche que des champs scalaires (xp, level, rank, streakGels) : - // save() n'écrit que ces chemins, sans réécrire l'inventaire déjà à jour. - ITEM_EFFECTS[itemType](user); - await user.save(); + // Effet 100% atomique côté DB (xp/level/rank/streakGels) — voir ITEM_EFFECTS. + const user = await ITEM_EFFECTS[itemType](req.user.id); + if (!user) return next(createError('Utilisateur introuvable.', 404)); const finalUser = await purgeEmptyEntries(req.user.id); diff --git a/back/routes/groupStreak.routes.js b/back/routes/groupStreak.routes.js index ad24a20..b7dd419 100644 --- a/back/routes/groupStreak.routes.js +++ b/back/routes/groupStreak.routes.js @@ -11,6 +11,7 @@ router.use(auth); // Ordre impératif : les routes littérales AVANT les routes paramétriques // pour éviter que Express interprète "my-group" comme un :groupId router.get('/my-group', groupStreak.getMyGroup); +router.post('/leave', groupStreak.leaveGroup); router.post('/invite', groupStreak.inviteToGroup); router.put('/respond/:groupId', groupStreak.respondToGroupInvite); router.post('/:groupId/shake/:memberId', groupStreak.shakeMember); diff --git a/back/tests/inventory.test.js b/back/tests/inventory.test.js index 78c50bd..a817e00 100644 --- a/back/tests/inventory.test.js +++ b/back/tests/inventory.test.js @@ -176,6 +176,54 @@ describe("Système Inventaire & Coffres Athly — V2", () => { expect(drink).toBeUndefined(); }); + it('🎯 ENERGY_DRINK : franchir un palier d\'XP recalcule level ET rank', async () => { + const { xpForLevel } = require('../utils/levelHelpers'); + // XP juste sous le seuil du niveau 11 (Rang Initié) + const justBelowLevel11 = xpForLevel(11) - 50; + await User.updateOne( + { _id: user.userId }, + { xp: justBelowLevel11, level: 10, rank: 'Novice', inventory: [{ itemType: 'ENERGY_DRINK', rarity: 'common', quantity: 1 }] }, + ); + + const res = await request(app) + .post('/api/inventory/item/use') + .set('Authorization', `Bearer ${user.token}`) + .send({ itemType: 'ENERGY_DRINK' }); + + expect(res.statusCode).toBe(200); + expect(res.body.user.xp).toBe(justBelowLevel11 + 150); + expect(res.body.user.level).toBe(11); + expect(res.body.user.rank).toBe('Initié'); + + const updatedUser = await User.findById(user.userId); + expect(updatedUser.level).toBe(11); + expect(updatedUser.rank).toBe('Initié'); + }); + + it('🔒 Anti race-condition : 5 utilisations simultanées d\'ENERGY_DRINK ne perdent aucun XP', async () => { + await User.updateOne( + { _id: user.userId }, + { xp: 0, inventory: [{ itemType: 'ENERGY_DRINK', rarity: 'common', quantity: 5 }] }, + ); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + request(app) + .post('/api/inventory/item/use') + .set('Authorization', `Bearer ${user.token}`) + .send({ itemType: 'ENERGY_DRINK' }), + ), + ); + + expect(results.every((r) => r.statusCode === 200)).toBe(true); + + // $inc atomique : les 5 gains de +150 XP doivent TOUS être comptabilisés, + // aucun perdu par un read-modify-write concurrent. + const updatedUser = await User.findById(user.userId); + expect(updatedUser.xp).toBe(750); + expect(updatedUser.inventory.find((i) => i.itemType === 'ENERGY_DRINK')).toBeUndefined(); + }); + it('✅ ENERGY_DRINK (qty 3) : 1 unité consommée, 2 restantes', async () => { await User.updateOne( { _id: user.userId }, diff --git a/back/tests/socialEngine.test.js b/back/tests/socialEngine.test.js index 9e6a9b8..9d2b2cc 100644 --- a/back/tests/socialEngine.test.js +++ b/back/tests/socialEngine.test.js @@ -291,14 +291,14 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { expect(res.statusCode).toBe(200); expect(res.body.allValidated).toBe(true); expect(res.body.currentStreak).toBe(1); - // 2 membres → multiplicateur x1.25 → 40 * 1.25 = 50 XP - expect(res.body.groupBonus.multiplier).toBeCloseTo(1.25); - expect(res.body.groupBonus.bonusXp).toBe(50); + // 2 membres → multiplicateur x1.35 → 50 * 1.35 = 68 XP + expect(res.body.groupBonus.multiplier).toBeCloseTo(1.35); + expect(res.body.groupBonus.bonusXp).toBe(68); const aliceDb = await User.findById(alice.userId); const bobDb = await User.findById(bob.userId); - expect(aliceDb.xp).toBe(50); - expect(bobDb.xp).toBe(50); + expect(aliceDb.xp).toBe(68); + expect(bobDb.xp).toBe(68); }); it('✅ Un membre manquant → pas de streak, pas de bonus', async () => { @@ -387,5 +387,105 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { expect(res.body.invites).toHaveLength(1); expect(res.body.invites[0].name).toBe('Team Recrutement'); }); + + it('🎯 getMyGroup expose le multiplicateur courant (taille + régularité)', async () => { + const group = await createValidatedGroup([alice, bob]); + await StreakGroup.updateOne({ _id: group._id }, { currentStreak: 14 }); // 2 semaines pile + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + // 2 membres → x1.35 taille ; 14j = 2 semaines → +16% régularité → x1.16 + expect(res.body.group.xpBonus.sizeMultiplier).toBeCloseTo(1.35); + expect(res.body.group.xpBonus.regularityMultiplier).toBeCloseTo(1.16); + expect(res.body.group.xpBonus.multiplier).toBeCloseTo(1.57); + }); + + it('🎯 Le bonus de régularité est plafonné à +60% même après 70+ jours', async () => { + await makeFriends(alice.userId, bob.userId); + const group = await createValidatedGroup([alice, bob]); + await StreakGroup.updateOne({ _id: group._id }, { currentStreak: 200 }); + + const res = await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + // currentStreak devient 201 → floor(201/7)=28 semaines, plafonné à +60% + expect(res.body.groupBonus.regularityMultiplier).toBeCloseTo(1.6); + expect(res.body.groupBonus.multiplier).toBeCloseTo(1.35 * 1.6); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 6. leaveGroup — quitter le groupe de streak + // ─────────────────────────────────────────────────────────────────────────── + describe('POST /api/groups/leave — leaveGroup', () => { + + it('✅ Un membre quitte : le groupe persiste pour les autres', async () => { + const group = await StreakGroup.create({ + name: 'Team Test', + members: [alice.userId, bob.userId, carol.userId], + pendingInvites: [], + }); + + const res = await request(app) + .post('/api/groups/leave') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.groupDeleted).toBe(false); + + const updated = await StreakGroup.findById(group._id); + expect(updated.members.map(String)).not.toContain(alice.userId); + expect(updated.members).toHaveLength(2); + }); + + it('✅ Le dernier membre quitte : le groupe est dissous', async () => { + const group = await StreakGroup.create({ + name: 'Solo', members: [alice.userId], pendingInvites: [], + }); + + const res = await request(app) + .post('/api/groups/leave') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.groupDeleted).toBe(true); + + const deleted = await StreakGroup.findById(group._id); + expect(deleted).toBeNull(); + }); + + it('✅ Après avoir quitté, l\'utilisateur peut rejoindre/créer un nouveau groupe', async () => { + await StreakGroup.create({ + name: 'Ancien groupe', members: [alice.userId, bob.userId], pendingInvites: [], + }); + await request(app) + .post('/api/groups/leave') + .set('Authorization', `Bearer ${alice.token}`); + + await makeFriends(alice.userId, carol.userId); + const res = await request(app) + .post('/api/groups/invite') + .set('Authorization', `Bearer ${alice.token}`) + .send({ friendIds: [carol.userId], name: 'Nouveau groupe' }); + + expect(res.statusCode).toBe(201); + }); + + it('❌ 404 si l\'utilisateur ne fait partie d\'aucun groupe', async () => { + const res = await request(app) + .post('/api/groups/leave') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(404); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/groups/leave'); + expect(res.statusCode).toBe(401); + }); }); }); diff --git a/front/src/components/common/ConfirmModal.js b/front/src/components/common/ConfirmModal.js new file mode 100644 index 0000000..04aa539 --- /dev/null +++ b/front/src/components/common/ConfirmModal.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +// ─── ConfirmModal ───────────────────────────────────────────────────────────── +// Modale de confirmation stylisée (fond sombre, carte, boutons) à utiliser à la +// place d'Alert.alert dès qu'une action mérite un rendu cohérent avec le reste +// de l'app plutôt que la boîte de dialogue système. +// +// Props : +// visible bool +// icon string — nom Ionicons affiché dans le badge +// title string +// body string +// confirmLabel string +// cancelLabel string (défaut "Annuler") +// destructive bool — accent rouge si true, orange sinon +// onConfirm () => void +// onCancel () => void + +export default function ConfirmModal({ + visible, icon = 'alert-circle-outline', title, body, + confirmLabel, cancelLabel = 'Annuler', destructive = false, + onConfirm, onCancel, +}) { + const accent = destructive ? '#EF4444' : Colors.primary; + + return ( + + + + + + + + {title} + {body ? {body} : null} + + + {confirmLabel} + + + + {cancelLabel} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + padding: 28, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + iconWrap: { + width: 60, + height: 60, + borderRadius: 18, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 18, + }, + title: { + color: Colors.textPrimary, + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 10, + textAlign: 'center', + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 24, + }, + confirmBtn: { + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 10, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + confirmTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + cancelBtn: { width: '100%', height: 44, borderRadius: 13, justifyContent: 'center', alignItems: 'center' }, + cancelTxt: { color: Colors.textMuted, fontSize: 14, fontWeight: '500' }, +}); diff --git a/front/src/components/profile/LevelUpCelebration.js b/front/src/components/profile/LevelUpCelebration.js new file mode 100644 index 0000000..03000b1 --- /dev/null +++ b/front/src/components/profile/LevelUpCelebration.js @@ -0,0 +1,48 @@ +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { useUser } from '../../context/UserContext'; +import LevelUpModal from './LevelUpModal'; + +// ─── LevelUpCelebration ─────────────────────────────────────────────────────── +// À monter une fois dans l'arbre authentifié (AppNavigator). Surveille +// `user.level` (backend) via le UserContext GLOBAL — n'importe quel appel à +// refetch() depuis n'importe quel écran (fin de séance, consommation d'objet +// d'inventaire, bonus de streak de groupe, parrainage…) met à jour `user`, et +// ce composant détecte l'augmentation et célèbre, quelle que soit la source. +// +// Le premier chargement mémorise le niveau sans célébrer (évite un faux +// déclenchement au démarrage de l'app). + +export default function LevelUpCelebration() { + const { user } = useUser(); + const previousLevelRef = useRef(null); + const [state, setState] = useState({ visible: false, level: null, rank: null }); + + useEffect(() => { + if (!user || typeof user.level !== 'number') return; + + if (previousLevelRef.current === null) { + previousLevelRef.current = user.level; + return; + } + + if (user.level > previousLevelRef.current) { + setState({ visible: true, level: user.level, rank: user.rank }); + } + previousLevelRef.current = user.level; + }, [user?.level, user?.rank]); + + const handleClose = useCallback(() => { + setState((s) => ({ ...s, visible: false })); + }, []); + + if (!state.visible) return null; + + return ( + + ); +} diff --git a/front/src/components/profile/LevelUpModal.js b/front/src/components/profile/LevelUpModal.js new file mode 100644 index 0000000..148d162 --- /dev/null +++ b/front/src/components/profile/LevelUpModal.js @@ -0,0 +1,148 @@ +import React, { useRef, useEffect } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import BirthdayConfetti from './BirthdayConfetti'; + +// Couleurs par rang — miroir de getRank() (stats.service.js) et +// getRankForLevel() (back/utils/levelHelpers.js), indexé par le libellé +// backend puisque cette modale reçoit `rank` en toutes lettres. +const RANK_COLORS = { + 'ATHLY GOD': '#FFD700', + 'Légende': '#C084FC', + 'Grand Maître': '#A855F7', + 'Maître': '#8B5CF6', + 'Élite': '#6E6AF0', + 'Warrior': '#6E6AF0', + 'Compétiteur': '#3B82F6', + 'Athlète': '#22C55E', + 'Initié': '#FBBF24', + 'Novice': '#FE7439', +}; + +// ─── LevelUpModal ───────────────────────────────────────────────────────────── +// Modale festive globale — déclenchée par LevelUpCelebration.js quel que soit +// la source du gain de niveau (séance, objet d'inventaire, bonus de groupe…). +// +// Props : +// visible bool +// level number +// rank string +// onClose () => void + +export default function LevelUpModal({ visible, level, rank, onClose }) { + const color = RANK_COLORS[rank] || Colors.primary; + const badgeScale = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!visible) return; + badgeScale.setValue(0); + Animated.spring(badgeScale, { toValue: 1, friction: 5, tension: 60, useNativeDriver: true }).start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + return ( + + + + + + + + + + NIVEAU SUPÉRIEUR + Niveau {level} + {rank} + + + Continue comme ça : chaque séance, chaque objet, chaque effort compte. + + + + Continuer + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + padding: 28, + alignItems: 'center', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.45, + shadowRadius: 32, + elevation: 20, + }, + badge: { + width: 76, + height: 76, + borderRadius: 24, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + eyebrow: { + color: Colors.textMuted, + fontSize: 11, + fontWeight: '800', + letterSpacing: 2, + marginBottom: 4, + }, + level: { + fontSize: 30, + fontWeight: '800', + letterSpacing: -0.5, + }, + rank: { + color: Colors.textSecondary, + fontSize: 14, + fontWeight: '700', + marginTop: 4, + marginBottom: 16, + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 22, + }, + closeBtn: { + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + closeBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/context/WorkoutLogsContext.js b/front/src/context/WorkoutLogsContext.js index fdc0c1a..204e358 100644 --- a/front/src/context/WorkoutLogsContext.js +++ b/front/src/context/WorkoutLogsContext.js @@ -1,5 +1,5 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; -import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog } from '../services/stats.service'; +import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog, addBonusXpLog } from '../services/stats.service'; // Context global pour l'historique des séances finalisées (logs). // Source de vérité unique pour StatsScreen, ExerciseStatsScreen, ProfileScreen. @@ -42,15 +42,16 @@ export function WorkoutLogsProvider({ children }) { const totalXP = useMemo(() => totalCumulativeXP(items), [items]); - // sessionLogs : séances uniquement (pas de quêtes, pas de rituels) — pour l'historique. + // sessionLogs : séances uniquement (pas de quêtes, pas de rituels, pas de bonus) — pour l'historique. const sessionLogs = useMemo( - () => items.filter((l) => l.type !== 'quest_reward' && l.type !== 'ritual'), + () => items.filter((l) => l.type !== 'quest_reward' && l.type !== 'ritual' && l.type !== 'item_bonus'), [items], ); - // activityLogs : toute activité valide pour le streak (pas de quêtes, pas de shortSession). + // activityLogs : toute activité valide pour le streak (pas de quêtes, pas de bonus, pas de shortSession). + // item_bonus (objet consommé, bonus de groupe) ne doit jamais compter comme une séance. const activityLogs = useMemo( - () => items.filter((l) => l.type !== 'quest_reward' && !l.shortSession), + () => items.filter((l) => l.type !== 'quest_reward' && l.type !== 'item_bonus' && !l.shortSession), [items], ); @@ -66,9 +67,19 @@ export function WorkoutLogsProvider({ children }) { return item; }, []); + // Synchronise l'XP/niveau affiché localement (Profil, Accueil) avec un gain + // accordé côté backend hors séance : objet d'inventaire consommé, bonus de + // streak de groupe... Voir addBonusXpLog (stats.service.js). + const addBonusXp = useCallback(async (source, xpEarned) => { + const item = await addBonusXpLog(source, xpEarned); + if (!item) return null; + setItems((prev) => [item, ...prev]); + return item; + }, []); + const value = useMemo( - () => ({ items, sessionLogs, activityLogs, loading, error, refresh, create, remove, addRitual, totalXP, clearAll }), - [items, sessionLogs, activityLogs, loading, error, refresh, create, remove, addRitual, totalXP, clearAll], + () => ({ items, sessionLogs, activityLogs, loading, error, refresh, create, remove, addRitual, addBonusXp, totalXP, clearAll }), + [items, sessionLogs, activityLogs, loading, error, refresh, create, remove, addRitual, addBonusXp, totalXP, clearAll], ); return ( diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js index 34c54ff..ae8df4e 100644 --- a/front/src/navigation/index.js +++ b/front/src/navigation/index.js @@ -20,6 +20,7 @@ import { TutorialProvider } from '../context/TutorialContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { setupNotificationChannels, ensureDailyRemindersScheduled } from '../services/notificationService'; import BirthdayCelebration from '../components/profile/BirthdayCelebration'; +import LevelUpCelebration from '../components/profile/LevelUpCelebration'; const NOTIF_ENABLED_KEY = 'athly:notif:enabled:v1'; @@ -60,6 +61,7 @@ export default function AppNavigator() { {userToken === null ? : ( <> + )} diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index 24cdf41..a9a24f4 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -7,8 +7,10 @@ import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from '@react-navigation/native'; import { Colors } from '../../constants/theme'; import { useUser } from '../../context/UserContext'; +import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import { useToast } from '../../context/ToastContext'; import { openChest, useItem, ITEM_CATALOG, RARITY_META } from '../../services/inventory.service'; +import { xpForLevel, xpToLevel } from '../../services/stats.service'; import ChestOpeningModal from '../../components/inventory/ChestOpeningModal'; const MIN_LEVEL_FOR_CHEST = 11; @@ -20,6 +22,7 @@ const RARITY_ORDER = ['unique', 'legendary', 'epic', 'rare', 'common']; export default function InventoryScreen({ navigation }) { const { user, refetch } = useUser(); + const { addBonusXp, totalXP } = useWorkoutLogs(); const { showToast } = useToast(); const [busy, setBusy] = useState(false); @@ -58,11 +61,27 @@ export default function InventoryScreen({ navigation }) { const handleUseItem = async (itemType) => { if (busy) return; setBusy(true); + const beforeXp = user?.xp ?? 0; + const beforeLevel = user?.level ?? 1; try { const res = await useItem(itemType); if (res.success) { showToast(`${ITEM_CATALOG[itemType]?.name ?? itemType} utilisé ! ✨`, 'success'); refetch(); + + // Le Profil affiche un niveau/XP calculé localement (logs de séances), + // distinct du user.xp backend. On pousse un log de synchronisation pour + // que l'usage d'un objet se reflète immédiatement sur le Profil/Accueil. + const xpGained = (res.user?.xp ?? beforeXp) - beforeXp; + const itemLabel = `Objet : ${ITEM_CATALOG[itemType]?.name ?? itemType}`; + if (xpGained > 0) { + addBonusXp(itemLabel, xpGained); + } else if ((res.user?.level ?? beforeLevel) > beforeLevel) { + // LEVEL_COUPON : pas de gain d'XP direct, on pousse localement de quoi + // franchir le prochain palier pour que le niveau affiché suive. + const needed = xpForLevel(xpToLevel(totalXP).level + 1) - totalXP; + if (needed > 0) addBonusXp(itemLabel, needed); + } } } catch (error) { if (error.isSessionExpired) return; diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 8c6994f..11c80b4 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -547,8 +547,8 @@ export default function SettingsScreen({ navigation }) { {/* ── SYNC BACKEND ── */} - Le niveau ci-dessus est local uniquement — les coffres et fonctionnalités - serveur (niveau 11+) lisent le niveau backend. Synchronise pour les tester. + Le niveau ci-dessus est local uniquement. Les coffres et fonctionnalités + serveur (niveau 11+) lisent le niveau backend, synchronise pour les tester. - Coffres et faux amis générés directement en base — pour tester + Coffres et faux amis générés directement en base, pour tester l'Inventaire et l'écran Social sans dizaines de vraies actions. @@ -580,8 +580,8 @@ export default function SettingsScreen({ navigation }) { /> - Crée FauxAmi_1 et FauxAmi_2 (amis acceptés — pour Classement et Groupe) - + FauxAmi_3 (demande en attente — pour Accepter/Refuser). Rejouable sans doublons. + Crée FauxAmi_1 et FauxAmi_2 (amis acceptés, pour Classement et Groupe) + et FauxAmi_3 (demande en attente, pour Accepter/Refuser). Rejouable sans doublons. {/* ── SIMULATION ── */} @@ -697,9 +697,9 @@ export default function SettingsScreen({ navigation }) { {/* ── RESET ── */} - Décale les séances d'aujourd'hui à hier — relance le gain d'XP. + Décale les séances d'aujourd'hui à hier, relance le gain d'XP. - Supprime uniquement les logs [DEBUG] — les vraies séances sont conservées. + Supprime uniquement les logs [DEBUG], les vraies séances sont conservées. {simLoading && ( diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index da07a51..de9dc34 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -74,12 +74,20 @@ export default function FriendProfileScreen({ route, navigation }) { {/* ── Lien d'amitié ── */} - - {'❤️'.repeat(profile.friendshipLevel)}{'🤍'.repeat(Math.max(0, 5 - profile.friendshipLevel))} - + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + Niveau d'amitié {profile.friendshipLevel}/5 - {profile.friendshipLevel === 5 ? ' — Lien de Sang 🩸' : ''} + {profile.friendshipLevel === 5 ? ' · Lien de Sang' : ''} {profile.friendshipXp} XP d'amitié @@ -149,7 +157,7 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(139,92,246,0.28)', borderRadius: 16, padding: 16, marginTop: 12, }, - friendshipHearts: { fontSize: 16, letterSpacing: 3, marginBottom: 6 }, + friendshipHearts: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 }, friendshipLevel: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '700' }, friendshipXp: { color: Colors.textMuted, fontSize: 11.5, marginTop: 2 }, diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index 69de018..d33b2d0 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -7,10 +7,13 @@ import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from '@react-navigation/native'; import { Colors } from '../../constants/theme'; import { useToast } from '../../context/ToastContext'; +import { useUser } from '../../context/UserContext'; +import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; +import ConfirmModal from '../../components/common/ConfirmModal'; import { searchUsers, sendFriendRequest, acceptFriendRequest, declineFriendRequest, getFriendsList, getPendingRequests, getLeaderboard, - getMyGroup, inviteToGroup, respondToGroupInvite, shakeMember, checkGroupStreak, + getMyGroup, inviteToGroup, respondToGroupInvite, shakeMember, checkGroupStreak, leaveGroup, } from '../../services/social.service'; const SEGMENTS = [ @@ -25,6 +28,8 @@ const SEGMENTS = [ export default function SocialScreen({ navigation }) { const { showToast } = useToast(); + const { refetch: refetchUser } = useUser(); + const { addBonusXp } = useWorkoutLogs(); const [segment, setSegment] = useState('friends'); // ── Données ── @@ -42,6 +47,9 @@ export default function SocialScreen({ navigation }) { const [searching, setSearching] = useState(false); const searchTimer = useRef(null); + // ── Confirmation quitter le groupe ── + const [leaveConfirmVisible, setLeaveConfirmVisible] = useState(false); + const loadAll = useCallback(async () => { try { const [friendsRes, pendingRes, boardRes, groupRes] = await Promise.all([ @@ -171,6 +179,14 @@ export default function SocialScreen({ navigation }) { const res = await checkGroupStreak(groupId); if (res.allValidated) { showToast(`Streak jour ${res.currentStreak} ! +${res.groupBonus?.bonusXp ?? 0} XP (x${res.groupBonus?.multiplier ?? 1}) 🔥`, 'success'); + // Le bonus XP peut faire franchir un palier de niveau, refetch + // le profil global pour que LevelUpCelebration le détecte, + // et pousse le même gain dans les logs locaux pour que le + // Profil (calculé localement) se mette à jour immédiatement. + refetchUser(); + if (res.groupBonus?.bonusXp > 0) { + addBonusXp('Bonus de groupe', res.groupBonus.bonusXp); + } } else if (res.alreadyValidated) { showToast('Déjà validée aujourd\'hui ✅', 'success'); } else { @@ -181,12 +197,28 @@ export default function SocialScreen({ navigation }) { if (!error.isSessionExpired) showToast(error.data?.message || 'Erreur.', 'error'); } }} + onLeaveGroup={() => setLeaveConfirmVisible(true)} /> )} )} + + { + setLeaveConfirmVisible(false); + doAction(() => leaveGroup(), 'Vous avez quitté le groupe.'); + }} + onCancel={() => setLeaveConfirmVisible(false)} + /> ); } @@ -354,7 +386,7 @@ function PodiumColumn({ entry, height, color, delay }) { // ═══ Segment Groupe ═══════════════════════════════════════════════════════════ -function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, onCheckStreak }) { +function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, onCheckStreak, onLeaveGroup }) { const [selectedIds, setSelectedIds] = useState([]); const [groupName, setGroupName] = useState(''); @@ -366,9 +398,12 @@ function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, o {/* ── Invitations reçues ── */} {invites.map((inv) => ( - - 🔥 Demande de Streak de Groupe — {inv.name || 'Sans nom'} - + + + + Demande de Streak de Groupe : {inv.name || 'Sans nom'} + + onRespond(inv._id, true)} /> onRespond(inv._id, false)} /> @@ -377,7 +412,7 @@ function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, o ))} {group ? ( - + ) : ( <> CRÉER UN GROUPE DE STREAK (MAX 5) @@ -397,6 +432,7 @@ function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, o Ajoute d'abord des amis pour former un groupe. ) : ( <> + SÉLECTIONNE TES AMIS {friends.map((f) => { const selected = selectedIds.includes(f.user._id); return ( @@ -435,8 +471,17 @@ function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, o ); } -function GroupCard({ group, onShake, onCheckStreak }) { +// Barème de référence (miroir de computeGroupXpBonus côté backend) : à taille +// et régularité fixées, quel multiplicateur peut-on espérer atteindre. +const SIZE_SCALE = [1, 2, 3, 4, 5].map((n) => ({ n, multiplier: 1 + 0.35 * (n - 1) })); +const REGULARITY_SCALE = [0, 7, 14, 21, 28, 35, 42, 49, 56].map((days) => ({ + days, + multiplier: 1 + Math.min(0.6, 0.08 * Math.floor(days / 7)), +})); + +function GroupCard({ group, onShake, onCheckStreak, onLeaveGroup }) { const flame = useRef(new Animated.Value(1)).current; + const [showScale, setShowScale] = useState(false); useEffect(() => { const loop = Animated.loop( @@ -451,21 +496,71 @@ function GroupCard({ group, onShake, onCheckStreak }) { }, []); const memberCount = group.members?.length ?? 0; - const multiplier = (1 + 0.25 * (memberCount - 1)).toFixed(2); + // xpBonus vient du backend (getMyGroup) — source de vérité pour le détail + // taille/régularité. Fallback taille-seule si absent (réponse en cache ancienne). + const xpBonus = group.xpBonus ?? { sizeMultiplier: 1 + 0.25 * (memberCount - 1), regularityMultiplier: 1, multiplier: 1 + 0.25 * (memberCount - 1) }; return ( - 🔥 + + + {group.name || 'Groupe de Streak'} Streak : {group.currentStreak ?? 0} jours - {' '}Multiplicateur : x{multiplier} + {/* ── Détail du multiplicateur d'XP ── */} + + + + Multiplicateur d'XP + ×{xpBonus.multiplier.toFixed(2)} + + + + Taille du groupe ({memberCount} membres) + ×{xpBonus.sizeMultiplier.toFixed(2)} + + + + Régularité ({group.currentStreak ?? 0}j de streak) + ×{xpBonus.regularityMultiplier.toFixed(2)} + + + setShowScale((v) => !v)} activeOpacity={0.75}> + {showScale ? 'Masquer le barème' : 'Voir le barème complet'} + + + + {showScale && ( + + + Taille + {SIZE_SCALE.map((row) => ( + + {row.n} {row.n > 1 ? 'membres' : 'membre'} + ×{row.multiplier.toFixed(2)} + + ))} + + + Régularité + {REGULARITY_SCALE.map((row) => ( + + {row.days === 0 ? '0 j' : `${row.days}+ j`} + ×{row.multiplier.toFixed(2)} + + ))} + + + )} + + MEMBRES ({memberCount}/5) {(group.members ?? []).map((m) => ( @@ -474,7 +569,8 @@ function GroupCard({ group, onShake, onCheckStreak }) { onPress={() => onShake(group._id, m._id, m.pseudo)} activeOpacity={0.8} > - 🚨 Secouer + + Secouer ))} @@ -483,6 +579,11 @@ function GroupCard({ group, onShake, onCheckStreak }) { Valider la streak du jour + + onLeaveGroup(group._id)} activeOpacity={0.75}> + + Quitter le groupe + ); } @@ -525,9 +626,17 @@ function UserRow({ user, children }) { function FriendshipHearts({ level }) { return ( - - {'❤️'.repeat(level)}{'🤍'.repeat(Math.max(0, 5 - level))} - + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + ); } @@ -606,7 +715,7 @@ const styles = StyleSheet.create({ userActions:{ flexDirection: 'row', alignItems: 'center', gap: 6 }, pendingTag: { color: Colors.textMuted, fontSize: 12, fontWeight: '600' }, friendTag: { color: Colors.valid, fontSize: 12, fontWeight: '700' }, - hearts: { fontSize: 10, letterSpacing: 1 }, + hearts: { flexDirection: 'row', alignItems: 'center' }, smallBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, @@ -645,7 +754,8 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(254,116,57,0.30)', borderRadius: 14, padding: 14, marginBottom: 10, }, - inviteTxt: { color: Colors.textPrimary, fontSize: 13.5, lineHeight: 19, marginBottom: 10 }, + inviteTxtRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 }, + inviteTxt: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, lineHeight: 19 }, inviteBtns: { flexDirection: 'row', gap: 8 }, groupCard: { @@ -654,22 +764,66 @@ const styles = StyleSheet.create({ borderRadius: 18, padding: 16, marginTop: 6, }, groupHeader: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - groupFlame: { fontSize: 34 }, + groupFlameWrap: { + width: 52, height: 52, borderRadius: 16, + backgroundColor: 'rgba(254,116,57,0.12)', + justifyContent: 'center', alignItems: 'center', + }, groupName: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' }, groupStreak: { color: Colors.textSecondary, fontSize: 12.5, marginTop: 3 }, + // ── Détail multiplicateur ── + multiplierCard: { + backgroundColor: 'rgba(255,215,0,0.05)', + borderWidth: 1, borderColor: 'rgba(255,215,0,0.18)', + borderRadius: 14, padding: 12, marginTop: 14, + }, + multiplierHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8 }, + multiplierTitle: { flex: 1, color: Colors.textPrimary, fontSize: 12.5, fontWeight: '700' }, + multiplierTotal: { color: Colors.gold, fontSize: 14, fontWeight: '800' }, + multiplierRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4 }, + multiplierLabel: { flex: 1, color: Colors.textMuted, fontSize: 11.5 }, + multiplierValue: { color: Colors.textSecondary, fontSize: 11.5, fontWeight: '700' }, + + scaleToggle: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5, + marginTop: 10, paddingTop: 10, + borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,215,0,0.18)', + }, + scaleToggleTxt: { color: Colors.gold, fontSize: 11.5, fontWeight: '700' }, + scaleWrap: { flexDirection: 'row', gap: 14, marginTop: 12 }, + scaleCol: { flex: 1 }, + scaleColTitle: { + color: Colors.textMuted, fontSize: 10.5, fontWeight: '800', + letterSpacing: 0.6, marginBottom: 6, + }, + scaleRow: { + flexDirection: 'row', justifyContent: 'space-between', + paddingVertical: 3, + }, + scaleRowLabel: { color: Colors.textSecondary, fontSize: 11.5 }, + scaleRowValue: { color: Colors.textPrimary, fontSize: 11.5, fontWeight: '700' }, + shakeBtn: { + flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(255,77,77,0.10)', borderWidth: 1, borderColor: 'rgba(255,77,77,0.40)', borderRadius: 9, paddingHorizontal: 10, height: 32, justifyContent: 'center', }, shakeTxt: { color: Colors.error, fontSize: 11.5, fontWeight: '800' }, + leaveBtn: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + height: 42, borderRadius: 13, marginTop: 10, + borderWidth: 1, borderColor: 'rgba(255,77,77,0.30)', + }, + leaveBtnTxt: { color: Colors.error, fontSize: 13, fontWeight: '700' }, + selectRow: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: Colors.cardDeep, borderWidth: 1, borderColor: Colors.borderSubtle, - borderRadius: 12, padding: 12, marginBottom: 7, + borderRadius: 12, padding: 12, marginBottom: 9, }, selectRowActive: { borderColor: 'rgba(254,116,57,0.45)' }, selectPseudo: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' }, diff --git a/front/src/services/inventory.service.js b/front/src/services/inventory.service.js index 97b661e..4b26491 100644 --- a/front/src/services/inventory.service.js +++ b/front/src/services/inventory.service.js @@ -12,7 +12,7 @@ export const ITEM_CATALOG = { }, STREAK_FREEZE: { name: 'Gel de Streak', icon: 'snow', rarity: 'rare', - description: 'Charge 1 gel — sauve ta streak en cas de jour manqué', usable: true, + description: 'Charge 1 gel, sauve ta streak en cas de jour manqué', usable: true, }, DOUBLE_XP: { name: 'Boost Double XP', icon: 'flash-outline', rarity: 'rare', @@ -36,11 +36,11 @@ export const ITEM_CATALOG = { }, CHEST_KEY: { name: 'Coffre', icon: 'cube', rarity: 'common', - description: 'Un coffre à ouvrir — que contient-il ?', usable: false, + description: 'Un coffre à ouvrir, que contient-il ?', usable: false, }, PROFILE_FRAME_BLOOD_BOND: { name: 'Cadre "Lien de Sang"', icon: 'shield', rarity: 'unique', - description: "Cosmétique Unique — niveau d'amitié 5. Introuvable en coffre.", usable: false, + description: "Cosmétique Unique (niveau d'amitié 5). Introuvable en coffre.", usable: false, }, }; diff --git a/front/src/services/social.service.js b/front/src/services/social.service.js index 2fae3c6..b2082a9 100644 --- a/front/src/services/social.service.js +++ b/front/src/services/social.service.js @@ -69,6 +69,11 @@ export async function checkGroupStreak(groupId) { return res.data; } +export async function leaveGroup() { + const res = await API.post('/groups/leave'); + return res.data; +} + // ─── Parrainage (Brique III) ────────────────────────────────────────────────── export async function claimReferral(code) { diff --git a/front/src/services/stats.service.js b/front/src/services/stats.service.js index dd28fce..dcd175e 100644 --- a/front/src/services/stats.service.js +++ b/front/src/services/stats.service.js @@ -315,6 +315,32 @@ export async function addRitualLog(ritualId, ritualLabel, durationSeconds = 300, return log; } +// Enregistre un gain d'XP dont la source n'est pas une séance (objet +// d'inventaire consommé, bonus de streak de groupe...). Sert uniquement à +// synchroniser l'XP/niveau affiché sur le Profil (calculé localement à partir +// des logs) avec les gains accordés côté backend. Exclu du streak et de +// l'historique de séances (voir WorkoutLogsContext : sessionLogs/activityLogs). +export async function addBonusXpLog(source, xpEarned) { + if (!xpEarned || xpEarned <= 0) return null; + const all = await readAll(); + const log = { + id: genId(), + date: new Date().toISOString(), + name: source, + type: 'item_bonus', + exercises: [], + totalVolume: 0, + setsCompleted: 0, + totalSets: 0, + muscleDistribution: {}, + durationSeconds: 0, + xpEarned: Math.round(xpEarned), + notes: '', + }; + await writeAll([log, ...all]); + return log; +} + export async function removeLog(id) { const all = await readAll(); await writeAll(all.filter((x) => x.id !== id)); From 25ed653344a64ca793f3b18e28e78c6d7464a83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 17:48:52 +0200 Subject: [PATCH 11/31] feat: synchronisation backend des cadres de profil, affichage de la vitrine des trophees v2 et refondu de la vue ami --- back/controllers/friend.controller.js | 98 ++++- back/controllers/user.controller.js | 30 +- back/models/User.js | 10 + back/routes/user.routes.js | 5 +- back/services/user.service.js | 16 + back/tests/socialEngine.test.js | 49 +++ back/tests/user.test.js | 33 ++ back/validators/user.validator.js | 7 +- .../components/profile/AchievementShowcase.js | 162 ++++++++ front/src/hooks/useAvatarFrame.js | 21 +- .../src/screens/Social/FriendProfileScreen.js | 351 ++++++++++++------ front/src/services/profile.service.js | 9 + 12 files changed, 663 insertions(+), 128 deletions(-) create mode 100644 front/src/components/profile/AchievementShowcase.js create mode 100644 front/src/services/profile.service.js diff --git a/back/controllers/friend.controller.js b/back/controllers/friend.controller.js index be80575..7530ac1 100644 --- a/back/controllers/friend.controller.js +++ b/back/controllers/friend.controller.js @@ -3,6 +3,9 @@ const mongoose = require('mongoose'); const Friendship = require('../models/Friendship'); const User = require('../models/User'); +const Workout = require('../models/Workout'); +const ExerciseRecord = require('../models/ExerciseRecord'); +const { ACHIEVEMENT_CATALOG, CATALOG_SIZE } = require('./reward.controller'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -24,6 +27,67 @@ function isValidId(id) { */ const FRIEND_PUBLIC_FIELDS = 'pseudo level rank xp'; +/** + * Streak courante (jours consécutifs) calculée depuis des dates de séances + * synchronisées côté backend. Approximation honnête : contrairement au calcul + * local (front), elle ne voit pas les rituels/activités 100% locales — mais + * c'est la seule donnée de séance disponible pour le profil d'un tiers. + */ +// Clé YYYY-MM-DD basée sur les composantes LOCALES (pas .toISOString(), qui +// décale au jour UTC précédent/suivant selon le fuseau du serveur). +function dayKey(d) { + const dt = new Date(d); + return `${dt.getFullYear()}-${dt.getMonth() + 1}-${dt.getDate()}`; +} + +function computeStreakFromDates(dates) { + const days = new Set(dates.map((d) => dayKey(d))); + const cur = new Date(); + cur.setHours(0, 0, 0, 0); + + if (!days.has(dayKey(cur))) cur.setDate(cur.getDate() - 1); + + let streak = 0; + while (days.has(dayKey(cur))) { + streak += 1; + cur.setDate(cur.getDate() - 1); + } + return streak; +} + +/** + * Étend le catalogue de trophées avec l'état débloqué/verrouillé pour un + * utilisateur donné — même logique que getUserAchievements, factorisée ici + * pour être réutilisée sur le profil public d'un ami. + */ +function buildAchievementsView(userAchievements) { + const unlockedMap = new Map(userAchievements.map((a) => [a.achievementId, a.unlockedAt])); + + const achievements = Object.values(ACHIEVEMENT_CATALOG).map((entry) => { + const unlocked = unlockedMap.has(entry.id); + const unlockedAt = unlockedMap.get(entry.id) ?? null; + + if (entry.hidden && !unlocked) { + return { + id: entry.id, name: '???', description: 'Ce trophée est encore secret.', + category: entry.category, hidden: true, unlocked: false, unlockedAt: null, + }; + } + return { ...entry, unlocked, unlockedAt }; + }); + + const unlockedCount = achievements.filter((a) => a.unlocked).length; + + return { + achievements, + stats: { + total: CATALOG_SIZE, + unlocked: unlockedCount, + percentage: Math.round((unlockedCount / CATALOG_SIZE) * 100), + }, + }; +} + // ───────────────────────────────────────────────────────────────────────────── // sendFriendRequest POST /api/friends/request // ───────────────────────────────────────────────────────────────────────────── @@ -322,8 +386,10 @@ exports.searchUsers = async (req, res, next) => { /** * Profil public d'un AMI ACCEPTÉ uniquement : identité de jeu, progression, - * trophées débloqués, records d'exercices (top 5 par poids), niveau d'amitié. - * 403 si la relation n'est pas acceptée — pas de fuite de données entre inconnus. + * cadre équipé, trophées débloqués (catalogue complet), stats de séances + * (total, streak approximée, minutes cumulées), records d'exercices, + * niveau d'amitié. 403 si la relation n'est pas acceptée — pas de fuite de + * données entre inconnus. */ exports.getFriendProfile = async (req, res, next) => { try { @@ -344,13 +410,14 @@ exports.getFriendProfile = async (req, res, next) => { } const friend = await User.findById(friendId) - .select('pseudo level rank xp achievements streakGels totalWorkoutMinutes createdAt'); + .select('pseudo level rank xp achievements streakGels totalWorkoutMinutes equippedFrame createdAt'); if (!friend) return next(createError('Utilisateur introuvable.', 404)); + const friendObjectId = new mongoose.Types.ObjectId(friendId); + // Top 5 des records par poids max soulevé - const ExerciseRecord = require('../models/ExerciseRecord'); - const records = await ExerciseRecord.aggregate([ - { $match: { user: new mongoose.Types.ObjectId(friendId) } }, + const recordsPromise = ExerciseRecord.aggregate([ + { $match: { user: friendObjectId } }, { $unwind: '$series' }, { $group: { _id: '$exerciceNom', @@ -361,13 +428,30 @@ exports.getFriendProfile = async (req, res, next) => { { $limit: 5 }, ]); + // Séances finalisées synchronisées côté backend (total + dates pour la streak) + const workoutsPromise = Workout.find({ + user: friendObjectId, + status: { $in: ['finished', 'completed'] }, + }).select('date'); + + const [records, workouts] = await Promise.all([recordsPromise, workoutsPromise]); + + const { achievements, stats: achievementsStats } = buildAchievementsView(friend.achievements); + return res.status(200).json({ success: true, profile: { user: friend, friendshipLevel: friendship.friendshipLevel, friendshipXp: friendship.friendshipXp, - achievementsCount: friend.achievements.length, + stats: { + totalSessions: workouts.length, + totalActiveDays: new Set(workouts.map((w) => dayKey(w.date))).size, + streak: computeStreakFromDates(workouts.map((w) => w.date)), + totalWorkoutMinutes: friend.totalWorkoutMinutes, + }, + achievements, + achievementsStats, records: records.map((r) => ({ exercice: r._id, maxPoids: r.maxPoids, diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index 2d4c2bf..c861511 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -37,11 +37,31 @@ exports.updateMe = async (req, res, next) => { try { // On passe l'ID de l'utilisateur et le corps de la requête au service const user = await userService.updateUser(req.user.id, req.body); - - res.status(200).json({ - success: true, - message: "Profil mis à jour avec succès", - user + + res.status(200).json({ + success: true, + message: "Profil mis à jour avec succès", + user + }); + } catch (error) { + next(error); + } +}; + +/** + * METTRE À JOUR LE CADRE DE PROFIL ÉQUIPÉ + * Synchronise le choix de cadre (forme + couleur) fait localement + * (BorderPicker) pour qu'il soit visible sur le profil public par les amis. + */ +exports.updateFrame = async (req, res, next) => { + try { + const { shapeId, colorId } = req.body; + const user = await userService.updateEquippedFrame(req.user.id, shapeId, colorId); + + res.status(200).json({ + success: true, + message: "Cadre mis à jour avec succès", + equippedFrame: user.equippedFrame, }); } catch (error) { next(error); diff --git a/back/models/User.js b/back/models/User.js index 7b8922e..a9417dd 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -128,6 +128,16 @@ const UserSchema = new mongoose.Schema( // ── Trophées / Succès V2 ───────────────────────────────────────────────── // Tableau des trophées débloqués. Le catalogue complet vit dans reward.controller.js. achievements: { type: [AchievementEntrySchema], default: [] }, + + // ── Cadre de profil équipé ───────────────────────────────────────────────── + // Synchronisé depuis le choix local (useAvatarFrame.js) pour que les amis + // voient le même cadre sur le profil public. shapeId/colorId sont des clés + // libres du catalogue front (BorderPicker.js) — pas d'enum ici pour ne pas + // dupliquer/figer ce catalogue côté backend. + equippedFrame: { + shapeId: { type: String, default: 'circle', maxlength: 40, trim: true }, + colorId: { type: String, default: 'none', maxlength: 40, trim: true }, + }, }, { timestamps: true } ); diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 3dfde3c..824a601 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const userController = require("../controllers/user.controller"); const auth = require("../middleware/auth.middleware"); const validate = require("../middleware/validate.middleware"); -const { updateProfile } = require("../validators/user.validator"); +const { updateProfile, updateFrame } = require("../validators/user.validator"); /** * ROUTES UTILISATEURS PROTEGEES @@ -16,6 +16,9 @@ router.get("/me", auth, userController.getMe); // Modifier mes infos (avec validation Joi) router.put("/me", auth, validate(updateProfile), userController.updateMe); +// Synchroniser le cadre de profil équipé (visible sur le profil public) +router.put("/me/frame", auth, validate(updateFrame), userController.updateFrame); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/user.service.js b/back/services/user.service.js index 87d4036..9355c53 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -51,6 +51,22 @@ class UserService { console.log(`🗑️ [deleteAccount] Utilisateur supprimé : ${userId}`); } + /** + * Met à jour atomiquement le cadre de profil équipé (forme + couleur). + * @param {string} userId + * @param {string} shapeId + * @param {string} colorId + */ + async updateEquippedFrame(userId, shapeId, colorId) { + const updatedUser = await User.findByIdAndUpdate( + userId, + { $set: { 'equippedFrame.shapeId': shapeId, 'equippedFrame.colorId': colorId } }, + { new: true, runValidators: true }, + ).select('equippedFrame'); + if (!updatedUser) throw new Error('Utilisateur non trouvé.'); + return updatedUser; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/socialEngine.test.js b/back/tests/socialEngine.test.js index 9e6a9b8..32aa4b9 100644 --- a/back/tests/socialEngine.test.js +++ b/back/tests/socialEngine.test.js @@ -154,6 +154,55 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { .set('Authorization', `Bearer ${alice.token}`); expect(res.statusCode).toBe(403); }); + + it('🎯 Expose le cadre équipé, le catalogue de trophées détaillé et les stats de séances', async () => { + await makeFriends(alice.userId, bob.userId); + await User.updateOne( + { _id: bob.userId }, + { + equippedFrame: { shapeId: 'hexagon', colorId: 'gold' }, + achievements: [{ achievementId: 'FIRST_REFERRAL' }], + }, + ); + await finishedWorkoutToday(bob.userId); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + + // Cadre équipé + expect(res.body.profile.user.equippedFrame).toEqual({ shapeId: 'hexagon', colorId: 'gold' }); + + // Stats de séances + expect(res.body.profile.stats.totalSessions).toBe(1); + expect(res.body.profile.stats.totalActiveDays).toBe(1); + expect(res.body.profile.stats.streak).toBe(1); + + // Catalogue de trophées complet, avec détail (pas juste un compteur) + expect(Array.isArray(res.body.profile.achievements)).toBe(true); + expect(res.body.profile.achievementsStats.unlocked).toBe(1); + const unlockedTrophy = res.body.profile.achievements.find((a) => a.id === 'FIRST_REFERRAL'); + expect(unlockedTrophy.unlocked).toBe(true); + expect(unlockedTrophy.name).not.toBe('???'); + + // Trophée caché non débloqué reste masqué (même règle que pour soi-même) + const hiddenLocked = res.body.profile.achievements.find((a) => a.id === 'BIRTHDAY_SET'); + expect(hiddenLocked.name).toBe('???'); + }); + + it('✅ Cadre par défaut si jamais synchronisé', async () => { + await makeFriends(alice.userId, bob.userId); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.profile.user.equippedFrame).toEqual({ shapeId: 'circle', colorId: 'none' }); + expect(res.body.profile.stats.totalSessions).toBe(0); + expect(res.body.profile.stats.streak).toBe(0); + }); }); // ─────────────────────────────────────────────────────────────────────────── diff --git a/back/tests/user.test.js b/back/tests/user.test.js index 09038e6..b1632e0 100644 --- a/back/tests/user.test.js +++ b/back/tests/user.test.js @@ -42,4 +42,37 @@ describe('User API (Routes Protégées)', () => { expect(res.statusCode).toEqual(200); expect(res.body.user).toHaveProperty('poids', 80); }); + + describe('PUT /api/users/me/frame — updateFrame', () => { + it('✅ Synchronise le cadre équipé (forme + couleur)', async () => { + const res = await request(app) + .put('/api/users/me/frame') + .set('Authorization', `Bearer ${token}`) + .send({ shapeId: 'hexagon', colorId: 'gold' }); + + expect(res.statusCode).toBe(200); + expect(res.body.equippedFrame).toEqual({ shapeId: 'hexagon', colorId: 'gold' }); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.equippedFrame.shapeId).toBe('hexagon'); + expect(user.equippedFrame.colorId).toBe('gold'); + }); + + it('❌ 400 si shapeId ou colorId manquant', async () => { + const res = await request(app) + .put('/api/users/me/frame') + .set('Authorization', `Bearer ${token}`) + .send({ shapeId: 'hexagon' }); + + expect(res.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app) + .put('/api/users/me/frame') + .send({ shapeId: 'hexagon', colorId: 'gold' }); + + expect(res.statusCode).toBe(401); + }); + }); }); \ No newline at end of file diff --git a/back/validators/user.validator.js b/back/validators/user.validator.js index 26d25a8..6a67a27 100644 --- a/back/validators/user.validator.js +++ b/back/validators/user.validator.js @@ -16,7 +16,12 @@ const userSchemas = { niveauSportif: Joi.string().valid("Débutant", "Intermédiaire", "Avancé"), objectif: Joi.string().valid("prise de masse", "perte de poids", "entretien", "force"), rythme: Joi.number().min(1).max(7).allow(null) - }) + }), + + updateFrame: Joi.object({ + shapeId: Joi.string().max(40).required(), + colorId: Joi.string().max(40).required(), + }), }; module.exports = userSchemas; \ No newline at end of file diff --git a/front/src/components/profile/AchievementShowcase.js b/front/src/components/profile/AchievementShowcase.js new file mode 100644 index 0000000..1f183f1 --- /dev/null +++ b/front/src/components/profile/AchievementShowcase.js @@ -0,0 +1,162 @@ +import React, { useRef, useEffect } from 'react'; +import { View, Text, StyleSheet, Animated } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import { RARITY_META } from '../../services/inventory.service'; + +// ─── AchievementShowcase ────────────────────────────────────────────────────── +// Vitrine des trophées backend V2 (reward.controller.js ACHIEVEMENT_CATALOG) : +// anniversaire, collection d'objets, social. Distinct du système de trophées +// V1 100% local (trophyCatalog.js / TrophyGrid) qui n'est pas consultable +// pour un tiers faute d'accès à ses logs de séances. +// +// Props : +// achievements Array<{ id, name, description, category, hidden, unlocked, unlockedAt }> +// stats { total, unlocked, percentage } + +const ICON_BY_ID = { + BIRTHDAY_SET: 'gift', + BIRTHDAY_CELEBRATED: 'balloon', + FIRST_COMMON_ITEM: 'cube-outline', + FIRST_RARE_ITEM: 'cube', + FIRST_EPIC_ITEM: 'diamond', + FIRST_LEGENDARY_ITEM: 'flame', + FIRST_UNIQUE_ITEM: 'star', + FIRST_REFERRAL: 'person-add', + FRIENDSHIP_LEVEL_5: 'heart', +}; + +const RARITY_BY_ID = { + FIRST_COMMON_ITEM: 'common', + FIRST_RARE_ITEM: 'rare', + FIRST_EPIC_ITEM: 'epic', + FIRST_LEGENDARY_ITEM: 'legendary', + FIRST_UNIQUE_ITEM: 'unique', +}; + +const CATEGORY_COLOR = { + profile: Colors.rankViolet, + collection: Colors.primary, + social: '#22D3EE', +}; + +function colorFor(achievement) { + const rarity = RARITY_BY_ID[achievement.id]; + if (rarity) return RARITY_META[rarity].color; + return CATEGORY_COLOR[achievement.category] || Colors.primary; +} + +export default function AchievementShowcase({ achievements = [], stats }) { + return ( + + {stats && ( + + + {stats.unlocked}/{stats.total} trophées débloqués + + + + + {stats.percentage}% + + )} + + + {achievements.map((a, i) => ( + + ))} + + + ); +} + +function AchievementBadge({ achievement, color, index }) { + const scale = useRef(new Animated.Value(0.85)).current; + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.spring(scale, { toValue: 1, friction: 7, tension: 60, delay: index * 40, useNativeDriver: true }), + Animated.timing(opacity, { toValue: 1, duration: 260, delay: index * 40, useNativeDriver: true }), + ]).start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { unlocked, hidden } = achievement; + const icon = hidden && !unlocked ? 'help-circle' : (ICON_BY_ID[achievement.id] || 'trophy'); + + return ( + + + + + + {achievement.name} + + {!unlocked && ( + + + + )} + + ); +} + +const styles = StyleSheet.create({ + statsBar: { + flexDirection: 'row', alignItems: 'center', gap: 10, + marginBottom: 16, + }, + statsTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600' }, + statsBarTrack: { + flex: 1, height: 6, borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.08)', overflow: 'hidden', + }, + statsBarFill: { height: '100%', backgroundColor: Colors.gold, borderRadius: 3 }, + statsPct: { color: Colors.gold, fontSize: 12, fontWeight: '800' }, + + grid: { + flexDirection: 'row', flexWrap: 'wrap', gap: 10, + }, + badge: { + width: '31%', + alignItems: 'center', + backgroundColor: Colors.glassBg, + borderWidth: 1, + borderColor: Colors.glassBorder, + borderRadius: 14, + paddingVertical: 14, + paddingHorizontal: 6, + position: 'relative', + }, + badgeIconWrap: { + width: 44, height: 44, borderRadius: 14, + borderWidth: 1, + justifyContent: 'center', alignItems: 'center', + marginBottom: 8, + }, + badgeIconWrapLocked: { + backgroundColor: 'rgba(255,255,255,0.04)', + borderColor: 'rgba(255,255,255,0.08)', + }, + badgeName: { + color: Colors.textMuted, + fontSize: 10.5, + fontWeight: '700', + textAlign: 'center', + lineHeight: 13, + }, + lockChip: { + position: 'absolute', top: 8, right: 8, + width: 16, height: 16, borderRadius: 8, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', alignItems: 'center', + }, +}); diff --git a/front/src/hooks/useAvatarFrame.js b/front/src/hooks/useAvatarFrame.js index edcc268..11fc0b6 100644 --- a/front/src/hooks/useAvatarFrame.js +++ b/front/src/hooks/useAvatarFrame.js @@ -1,5 +1,6 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { updateEquippedFrame } from '../services/profile.service'; const SHAPE_KEY = 'athly:avatarShape:v1'; const COLOR_KEY = 'athly:avatarColor:v1'; @@ -7,6 +8,10 @@ const COLOR_KEY = 'athly:avatarColor:v1'; export function useAvatarFrame() { const [shapeId, setShapeId] = useState('circle'); const [colorId, setColorId] = useState('none'); + // Toujours à jour de façon synchrone (contrairement au state React) pour + // envoyer la paire {shapeId, colorId} complète au backend, même quand un + // seul des deux vient de changer. + const latest = useRef({ shapeId: 'circle', colorId: 'none' }); useEffect(() => { Promise.all([ @@ -15,18 +20,28 @@ export function useAvatarFrame() { ]).then(([shape, color]) => { if (shape) setShapeId(shape); if (color) setColorId(color); + latest.current = { shapeId: shape || 'circle', colorId: color || 'none' }; }).catch(() => {}); }, []); + // Fire-and-forget : la synchro backend ne doit jamais bloquer/casser + // l'affichage local (source de vérité pour soi-même). + const syncBackend = useCallback((next) => { + latest.current = next; + updateEquippedFrame(next.shapeId, next.colorId).catch(() => {}); + }, []); + const selectShape = useCallback(async (id) => { setShapeId(id); try { await AsyncStorage.setItem(SHAPE_KEY, id); } catch (e) {} - }, []); + syncBackend({ ...latest.current, shapeId: id }); + }, [syncBackend]); const selectColor = useCallback(async (id) => { setColorId(id); try { await AsyncStorage.setItem(COLOR_KEY, id); } catch (e) {} - }, []); + syncBackend({ ...latest.current, colorId: id }); + }, [syncBackend]); return { shapeId, colorId, selectShape, selectColor }; } diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index da07a51..88be548 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -1,169 +1,298 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, StatusBar, ActivityIndicator, Animated, } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { useToast } from '../../context/ToastContext'; -import { getFriendProfile } from '../../services/social.service'; +import { getRank, xpToLevel } from '../../services/stats.service'; +import { getFriendProfile, getMyGroup, shakeMember } from '../../services/social.service'; + +import HeroLevelCard from '../../components/profile/HeroLevelCard'; +import StreakBadge from '../../components/profile/StreakBadge'; +import EmberParticles from '../../components/profile/EmberParticles'; +import AchievementShowcase from '../../components/profile/AchievementShowcase'; // ─── FriendProfileScreen ────────────────────────────────────────────────────── -// Profil public d'un ami (Brique III) : progression, lien d'amitié, records. +// Profil public d'un ami (Brique III) : miroir en lecture seule de notre propre +// ProfileScreen — même hero card (avec le cadre équipé de l'ami), même jauge +// d'XP/niveau/rang, mêmes stats globales, et la vitrine complète des trophées +// backend V2. Aucune action personnelle (Sac, Modifier le profil) : uniquement +// des actions sociales légitimes (Secouer si coéquipier, statut d'amitié). +// // Le backend refuse (403) si la relation n'est pas acceptée. +function buildBgGradient(isGod, isLegend, isElite) { + if (isGod) return ['#0A0800', '#100D02', Colors.bgAbyss, '#08080E']; + if (isLegend) return [Colors.bgAbyss, '#0C0816', '#0D0A1A', Colors.bgAbyss]; + if (isElite) return [Colors.bgAbyss, '#0A0A18', '#0D0D1C', Colors.bgAbyss]; + return [Colors.bgAbyss, '#09090F', Colors.bgAbyss]; +} + export default function FriendProfileScreen({ route, navigation }) { const { friendId, pseudo } = route.params ?? {}; const { showToast } = useToast(); + const insets = useSafeAreaInsets(); + + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [sharedGroup, setSharedGroup] = useState(null); // groupe où l'ami et moi sommes tous deux membres + const [shaking, setShaking] = useState(false); + + const opacity = useRef(new Animated.Value(0)).current; - const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(true); - - const fade = useRef(new Animated.Value(0)).current; - - useEffect(() => { - (async () => { - try { - const res = await getFriendProfile(friendId); - setProfile(res.profile); - Animated.timing(fade, { toValue: 1, duration: 360, useNativeDriver: true }).start(); - } catch (error) { - if (!error.isSessionExpired) { - showToast(error.data?.message || 'Profil inaccessible.', 'error'); - } - navigation.goBack(); - } finally { - setLoading(false); + const load = useCallback(async () => { + try { + const [profileRes, groupRes] = await Promise.all([ + getFriendProfile(friendId), + getMyGroup().catch(() => null), + ]); + setProfile(profileRes.profile); + + const group = groupRes?.group; + const isSharedGroup = group?.members?.some((m) => m._id === friendId); + setSharedGroup(isSharedGroup ? group : null); + + Animated.timing(opacity, { toValue: 1, duration: 280, useNativeDriver: true }).start(); + } catch (error) { + if (!error.isSessionExpired) { + showToast(error.data?.message || 'Profil inaccessible.', 'error'); } - })(); + navigation.goBack(); + } finally { + setLoading(false); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [friendId]); + useEffect(() => { load(); }, [load]); + + const handleShake = async () => { + if (shaking || !sharedGroup) return; + setShaking(true); + try { + const res = await shakeMember(sharedGroup._id, friendId); + showToast(res.message || `${pseudo} a été secoué ! 🚨`, 'success'); + } catch (error) { + if (!error.isSessionExpired) showToast(error.data?.message || 'Action impossible.', 'error'); + } finally { + setShaking(false); + } + }; + + const level = useMemo(() => xpToLevel(profile?.user?.xp ?? 0).level, [profile]); + const rank = useMemo(() => getRank(level), [level]); + const isElite = level >= 91; + const isLegend = level >= 171; + const isGod = level >= 200; + const bgColors = buildBgGradient(isGod, isLegend, isElite); + const topPad = insets.top + 16; + + if (loading) { + return ( + + + + + + + + ); + } + + if (!profile) return null; + + const equippedFrame = profile.user.equippedFrame || { shapeId: 'circle', colorId: 'none' }; + return ( - - - - navigation.goBack()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> - - - {pseudo ?? 'Profil'} - - + + - {loading ? ( - - ) : profile && ( - - + navigation.goBack()} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + activeOpacity={0.7} + > + + - {/* ── Identité de jeu ── */} - - - {(profile.user.pseudo ?? '?').charAt(0).toUpperCase()} - - {profile.user.pseudo} - {profile.user.rank} · Niveau {profile.user.level} + + - - - - - + {/* ── Hero Level Card (cadre équipé de l'ami) ── */} + + + + + + {/* ── Streak ── */} + {profile.stats.streak > 0 && ( + + + )} - {/* ── Lien d'amitié ── */} - - - {'❤️'.repeat(profile.friendshipLevel)}{'🤍'.repeat(Math.max(0, 5 - profile.friendshipLevel))} - - - Niveau d'amitié {profile.friendshipLevel}/5 - {profile.friendshipLevel === 5 ? ' — Lien de Sang 🩸' : ''} - - {profile.friendshipXp} XP d'amitié + {/* ── Statut d'amitié ── */} + + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + + Niveau d'amitié {profile.friendshipLevel}/5 + {profile.friendshipLevel === 5 ? ' · Lien de Sang' : ''} + + {profile.friendshipXp} XP d'amitié + - {/* ── Records ── */} - RECORDS - {profile.records.length === 0 ? ( - Aucun record enregistré pour le moment. - ) : profile.records.map((r) => ( - - - {r.exercice} - {r.maxPoids} kg × {r.maxReps} + {/* ── Action sociale : Secouer (uniquement si coéquipier de streak) ── */} + {sharedGroup && ( + + + {shaking + ? + : } - ))} + + Coéquipier de streak + Secoue {profile.user.pseudo} s'il n'a pas encore fait sa séance + + + + )} + + {/* ── Vitrine des trophées ── */} +
+ + + +
+ + {/* ── Records personnels ── */} +
+ + {profile.records.length === 0 ? ( + + + Aucun record enregistré pour le moment. + + ) : profile.records.map((r, i) => ( + + + {r.exercice} + {r.maxPoids} kg × {r.maxReps} + + ))} + +
- -
+
- )} +
); } -function Stat({ label, value }) { +// ─── Sub-components (miroir de ProfileScreen.js) ────────────────────────────── + +function Section({ title, children }) { return ( - - {value} - {label} + + {title} + {children} ); } +function GlassCard({ children }) { + return {children}; +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: Colors.bgAbyss }, - header: { - flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 16, paddingTop: 54, paddingBottom: 14, - }, - headerTitle: { color: Colors.textPrimary, fontSize: 17, fontWeight: '800' }, - loadingBox: { flex: 1, justifyContent: 'center', alignItems: 'center' }, - content: { paddingHorizontal: 16 }, + root: { flex: 1, backgroundColor: Colors.bgAbyss }, + scroll: { flex: 1 }, + scrollContent: { paddingHorizontal: 20, paddingBottom: 40 }, + centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, - heroCard: { - alignItems: 'center', - backgroundColor: Colors.cardDeep, - borderWidth: 1, borderColor: Colors.borderSubtle, - borderRadius: 18, padding: 22, marginTop: 6, - }, - bigAvatar: { - width: 68, height: 68, borderRadius: 20, - backgroundColor: 'rgba(254,116,57,0.14)', - justifyContent: 'center', alignItems: 'center', marginBottom: 12, - }, - bigAvatarTxt: { color: Colors.primary, fontSize: 28, fontWeight: '800' }, - pseudo: { color: Colors.textPrimary, fontSize: 20, fontWeight: '800', letterSpacing: -0.3 }, - rank: { color: Colors.textSecondary, fontSize: 13, marginTop: 3, marginBottom: 16 }, + backBtn: { position: 'absolute', left: 20, zIndex: 10 }, - statRow: { flexDirection: 'row', width: '100%' }, - stat: { flex: 1, alignItems: 'center' }, - statValue: { color: Colors.textPrimary, fontSize: 17, fontWeight: '800' }, - statLabel: { color: Colors.textMuted, fontSize: 11, marginTop: 2 }, + heroWrapper: { position: 'relative' }, + streakWrap: { marginTop: 10 }, friendshipCard: { alignItems: 'center', backgroundColor: 'rgba(139,92,246,0.07)', borderWidth: 1, borderColor: 'rgba(139,92,246,0.28)', - borderRadius: 16, padding: 16, marginTop: 12, + borderRadius: 16, padding: 16, marginTop: 14, }, - friendshipHearts: { fontSize: 16, letterSpacing: 3, marginBottom: 6 }, - friendshipLevel: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '700' }, + friendshipHearts: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 }, + friendshipLabel: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '700' }, friendshipXp: { color: Colors.textMuted, fontSize: 11.5, marginTop: 2 }, - sectionLabel: { - color: Colors.textMuted, fontSize: 11, fontWeight: '700', - letterSpacing: 0.8, marginTop: 22, marginBottom: 8, marginLeft: 4, + shakeBanner: { + flexDirection: 'row', alignItems: 'center', gap: 12, + marginTop: 12, + backgroundColor: 'rgba(255,77,77,0.07)', + borderWidth: 1, borderColor: 'rgba(255,77,77,0.28)', + borderRadius: 14, paddingVertical: 12, paddingHorizontal: 14, + }, + shakeIconBox: { + width: 36, height: 36, borderRadius: 11, + backgroundColor: 'rgba(255,77,77,0.12)', + justifyContent: 'center', alignItems: 'center', + }, + shakeTitle: { color: Colors.textPrimary, fontSize: 13, fontWeight: '700' }, + shakeSub: { color: Colors.textMuted, fontSize: 11, marginTop: 1 }, + + section: { marginTop: 26 }, + sectionTitle: { + color: Colors.textPrimary, fontSize: 11, fontWeight: '800', + letterSpacing: 1.4, textTransform: 'uppercase', marginBottom: 12, + }, + glassCard: { + backgroundColor: Colors.glassBg, + borderWidth: 1, borderColor: Colors.glassBorder, + borderRadius: 18, padding: 14, overflow: 'hidden', }, + recordRow: { flexDirection: 'row', alignItems: 'center', - backgroundColor: Colors.cardDeep, - borderWidth: 1, borderColor: Colors.borderSubtle, - borderRadius: 12, paddingHorizontal: 14, height: 46, marginBottom: 7, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.borderSubtle, }, recordName: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600', marginRight: 8 }, recordValue: { color: Colors.primary, fontSize: 13, fontWeight: '800' }, - emptyTxt: { color: Colors.textMuted, fontSize: 12.5, marginLeft: 4 }, + + emptyRecords: { alignItems: 'center', paddingVertical: 20, gap: 8 }, + emptyTxt: { color: Colors.textMuted, fontSize: 12.5, textAlign: 'center' }, }); diff --git a/front/src/services/profile.service.js b/front/src/services/profile.service.js new file mode 100644 index 0000000..3010618 --- /dev/null +++ b/front/src/services/profile.service.js @@ -0,0 +1,9 @@ +import API from '../api/api'; + +// Synchronise le cadre de profil équipé (forme + couleur) vers le backend, +// pour qu'il soit visible sur le profil public par les amis (useAvatarFrame +// reste la source de vérité locale pour l'affichage instantané côté soi-même). +export async function updateEquippedFrame(shapeId, colorId) { + const res = await API.put('/users/me/frame', { shapeId, colorId }); + return res.data; +} From c5ca9aaffe7ffc6747b1758a66bfbc5e9346eb69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 2 Jul 2026 17:58:10 +0200 Subject: [PATCH 12/31] fix: corrige un bloc JSX duplique par un merge dans FriendProfileScreen Le merge de develop avait laisse deux copies du bloc "lien d'amitie" (l'une correctement fermee, l'autre orpheline avec un sans ouverture correspondante), cassant le parsing JSX. Dedoublonne et corrige au passage une reference a un style inexistant (friendshipLabel -> friendshipLevel). Co-Authored-By: Claude Sonnet 5 --- .../src/screens/Social/FriendProfileScreen.js | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index 5aec4b6..e71eff8 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -152,26 +152,20 @@ export default function FriendProfileScreen({ route, navigation }) {
)} - {/* ── Lien d'amitié ── */} - - - {Array.from({ length: 5 }, (_, i) => ( - - ))} - - - Niveau d'amitié {profile.friendshipLevel}/5 - {profile.friendshipLevel === 5 ? ' · Lien de Sang' : ''} - - {profile.friendshipXp} XP d'amitié + {/* ── Statut d'amitié ── */} + + + {Array.from({ length: 5 }, (_, i) => ( + + ))} - + Niveau d'amitié {profile.friendshipLevel}/5 {profile.friendshipLevel === 5 ? ' · Lien de Sang' : ''} From cd15213134f7d84668e7381cf84f524168373730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 3 Jul 2026 12:14:00 +0200 Subject: [PATCH 13/31] feat: synchronisation des 50 trophees, vitrine de profil ami, classements par records d'exercice et parrainage complet a l'inscription --- back/controllers/auth.controller.js | 4 +- back/controllers/debug.controller.js | 170 +++++++- back/controllers/exercise.controller.js | 76 +++- back/controllers/friend.controller.js | 22 +- back/controllers/reward.controller.js | 51 +++ back/controllers/user.controller.js | 20 + back/data/localTrophyCatalog.js | 166 ++++++++ back/models/User.js | 5 + back/routes/exercise.routes.js | 3 + back/routes/reward.routes.js | 6 +- back/routes/user.routes.js | 5 +- back/services/auth.service.js | 86 +++- back/services/user.service.js | 33 ++ back/tests/debug.test.js | 14 +- back/tests/deepIntegration.test.js | 393 ++++++++++++++++++ back/validators/auth.validator.js | 4 + back/validators/user.validator.js | 4 + .../components/profile/AchievementShowcase.js | 199 +++++++-- .../components/profile/FriendShowcaseGrid.js | 49 +++ front/src/components/profile/TrophyGrid.js | 192 +-------- front/src/components/profile/TrophySlot.js | 199 +++++++++ front/src/hooks/useFeaturedTrophies.js | 4 + front/src/screens/Auth/RegisterScreen.js | 18 +- front/src/screens/Profile/ProfileScreen.js | 14 + front/src/screens/Profile/SettingsScreen.js | 58 ++- .../src/screens/Social/FriendProfileScreen.js | 9 + front/src/screens/Social/SocialScreen.js | 124 +++++- front/src/services/profile.service.js | 7 + front/src/services/reward.service.js | 8 + front/src/services/social.service.js | 6 + 30 files changed, 1669 insertions(+), 280 deletions(-) create mode 100644 back/data/localTrophyCatalog.js create mode 100644 back/tests/deepIntegration.test.js create mode 100644 front/src/components/profile/FriendShowcaseGrid.js create mode 100644 front/src/components/profile/TrophySlot.js diff --git a/back/controllers/auth.controller.js b/back/controllers/auth.controller.js index 4ecc7c5..861904b 100644 --- a/back/controllers/auth.controller.js +++ b/back/controllers/auth.controller.js @@ -3,8 +3,8 @@ const authService = require("../services/auth.service"); // ── Inscription ─────────────────────────────────────────────────────────────── exports.registerUser = async (req, res, next) => { try { - const { pseudo, email, password } = req.body; - const result = await authService.register(pseudo, email, password); + const { pseudo, email, password, referralCode } = req.body; + const result = await authService.register(pseudo, email, password, referralCode); res.status(201).json({ success: true, ...result }); } catch (error) { next(error); diff --git a/back/controllers/debug.controller.js b/back/controllers/debug.controller.js index 537af63..3a1bb3d 100644 --- a/back/controllers/debug.controller.js +++ b/back/controllers/debug.controller.js @@ -5,7 +5,8 @@ const bcrypt = require('bcrypt'); const User = require('../models/User'); const Friendship = require('../models/Friendship'); const { xpForLevel, getRankForLevel } = require('../utils/levelHelpers'); -const { addItemAtomic } = require('../services/inventory.service'); +const { addItemAtomic, addUniqueItemOnce } = require('../services/inventory.service'); +const { checkAndUnlockAchievements } = require('./reward.controller'); const MOCK_PASSWORD_ROUNDS = 10; // comptes jetables, jamais utilisés pour se connecter @@ -99,40 +100,115 @@ exports.giveChests = async (req, res, next) => { // ───────────────────────────────────────────────────────────────────────────── // 3 profils types couvrant les usages de l'écran Social : -// - 2 amis "accepted" → alimentent le Classement/Podium et permettent de -// créer un Groupe de Streak. +// - 2 amis "accepted" complets → Classement/Podium, Groupe de Streak, +// profil public riche (cadre, trophées, vitrine, records, séances). // - 1 ami "pending" (le faux profil est le requester, l'appelant le // recipient) → apparaît dans les demandes reçues, pour tester // Accepter/Refuser. +// Les données (cadres, inventaires, trophées, records) sont FIXES et non +// aléatoires : chaque relance de l'outil reproduit exactement le même état, +// ce qui rend les scénarios de test répétables. const MOCK_FRIEND_SPECS = [ - { suffix: 1, status: 'accepted' }, - { suffix: 2, status: 'accepted' }, - { suffix: 3, status: 'pending' }, + { + suffix: 1, status: 'accepted', level: 24, + friendshipXp: 120, friendshipLevel: 2, + frame: { shapeId: 'hexagon', colorId: 'bronze' }, + inventory: [ + { itemType: 'ENERGY_DRINK', rarity: 'common', quantity: 3 }, + { itemType: 'STREAK_FREEZE', rarity: 'rare', quantity: 2 }, + { itemType: 'CHEST_KEY', rarity: 'common', quantity: 1 }, + ], + achievements: ['ignition', 'promise', 'iron_will', 'marathonien', 'first_friend', 'FIRST_COMMON_ITEM'], + showcase: ['promise', 'iron_will', 'marathonien'], + workoutDays: [0, 1, 2, 4, 6, 9], // jours dans le passé (0 = aujourd'hui) → streak de 3 + records: [ + { exercice: 'Développé couché', series: [{ poids: 80, repetitions: 8 }, { poids: 85, repetitions: 5 }] }, + { exercice: 'Squat', series: [{ poids: 110, repetitions: 6 }] }, + { exercice: 'Soulevé de terre', series: [{ poids: 130, repetitions: 4 }] }, + ], + }, + { + suffix: 2, status: 'accepted', level: 38, + friendshipXp: 240, friendshipLevel: 3, + frame: { shapeId: 'octagon', colorId: 'silver' }, + inventory: [ + { itemType: 'TRIPLE_XP', rarity: 'epic', quantity: 1 }, + { itemType: 'LEVEL_COUPON', rarity: 'legendary', quantity: 1 }, + ], + achievements: ['ignition', 'promise', 'apprenti', 'titan', 'machine', 'night_owl', 'streak_hunter', 'FIRST_RARE_ITEM', 'FIRST_EPIC_ITEM'], + showcase: ['titan', 'night_owl', 'apprenti'], + workoutDays: [0, 1, 2, 3, 4, 5, 7, 10, 14], // streak de 6 + records: [ + { exercice: 'Développé couché', series: [{ poids: 100, repetitions: 6 }, { poids: 105, repetitions: 3 }] }, + { exercice: 'Squat', series: [{ poids: 140, repetitions: 5 }] }, + { exercice: 'Développé militaire', series: [{ poids: 55, repetitions: 8 }] }, + ], + }, + { + suffix: 3, status: 'pending', level: 12, + friendshipXp: 0, friendshipLevel: 1, + frame: { shapeId: 'circle', colorId: 'none' }, + inventory: [], + achievements: ['ignition'], + showcase: [], + workoutDays: [1, 3], + records: [ + { exercice: 'Développé couché', series: [{ poids: 60, repetitions: 10 }] }, + ], + }, + { + // Amitié niveau max (Lien de Sang) : pour tester la vitrine "vieille garde" + // et le trophée/cadre uniques FRIENDSHIP_LEVEL_5 / PROFILE_FRAME_BLOOD_BOND. + suffix: 4, status: 'accepted', level: 61, + friendshipXp: 1500, friendshipLevel: 5, + frame: { shapeId: 'hexagon', colorId: 'gold' }, + inventory: [ + { itemType: 'PROFILE_FRAME_BLOOD_BOND', rarity: 'unique', quantity: 1 }, + { itemType: 'CHEST_KEY', rarity: 'common', quantity: 2 }, + ], + achievements: ['ignition', 'promise', 'apprenti', 'centurion', 'iron_discipline', 'first_friend', 'mentor', 'FIRST_UNIQUE_ITEM', 'FRIENDSHIP_LEVEL_5'], + showcase: ['centurion', 'mentor', 'iron_discipline'], + workoutDays: [0, 1, 2, 3, 5, 6, 8, 11, 15, 20, 27], // streak de 4, ancienneté marquée + records: [ + { exercice: 'Développé couché', series: [{ poids: 90, repetitions: 7 }] }, + { exercice: 'Squat', series: [{ poids: 125, repetitions: 6 }] }, + { exercice: 'Traction lestée', series: [{ poids: 20, repetitions: 8 }] }, + ], + }, ]; /** - * Outil de test : génère (ou régénère) un petit réseau social factice - * pour l'utilisateur connecté — 3 faux comptes User + leurs Friendship. + * Outil de test : génère (ou régénère) un réseau social factice COMPLET pour + * l'utilisateur connecté — 3 faux comptes avec cadres équipés, inventaires, + * trophées + vitrines, historiques de séances et records d'exercices. De quoi + * tester le profil public riche, le classement XP et le classement par + * exercice sans aucun vrai compte tiers. * - * Idempotent : les faux amis précédemment générés PAR CET utilisateur - * (namespacés par son ObjectId dans l'email) sont supprimés avant d'en - * recréer de nouveaux, pour permettre de relancer l'outil sans accumuler - * des dizaines de doublons "FauxAmi_*" en base. + * Idempotent : les faux amis précédents de CET utilisateur (namespacés par + * son ObjectId dans l'email) et TOUTES leurs données liées (amitiés, séances, + * records) sont supprimés avant recréation. */ exports.mockSocial = async (req, res, next) => { try { + const Workout = require('../models/Workout'); + const ExerciseRecord = require('../models/ExerciseRecord'); + const myId = req.user.id; const emailPrefix = `mock-${myId}-`; - // ── Nettoyage des faux amis précédents de CET utilisateur ──────────────── + // ── Nettoyage complet des faux amis précédents de CET utilisateur ──────── const previousMocks = await User.find({ email: { $regex: `^${emailPrefix}` }, }).select('_id'); const previousIds = previousMocks.map((u) => u._id); if (previousIds.length > 0) { - await Friendship.deleteMany({ - $or: [{ requester: { $in: previousIds } }, { recipient: { $in: previousIds } }], - }); + await Promise.all([ + Friendship.deleteMany({ + $or: [{ requester: { $in: previousIds } }, { recipient: { $in: previousIds } }], + }), + Workout.deleteMany({ user: { $in: previousIds } }), + ExerciseRecord.deleteMany({ user: { $in: previousIds } }), + ]); await User.deleteMany({ _id: { $in: previousIds } }); } @@ -141,29 +217,77 @@ exports.mockSocial = async (req, res, next) => { const created = []; for (const spec of MOCK_FRIEND_SPECS) { - const level = Math.floor(Math.random() * 40) + 5; // 5–44 : mixe plusieurs rangs const mockUser = await User.create({ pseudo: `FauxAmi_${spec.suffix}`, email: `${emailPrefix}${spec.suffix}@athly.dev`, password: passwordHash, isVerified: true, - level, - xp: xpForLevel(level), - rank: getRankForLevel(level), + level: spec.level, + xp: xpForLevel(spec.level), + rank: getRankForLevel(spec.level), + equippedFrame: spec.frame, + inventory: spec.inventory, + achievements: spec.achievements.map((achievementId) => ({ achievementId })), + showcasedAchievements: spec.showcase, + totalWorkoutMinutes: spec.workoutDays.length * 55, }); + // Historique de séances finalisées, réparties sur les derniers jours + const workouts = await Workout.insertMany( + spec.workoutDays.map((daysAgo, i) => { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + date.setHours(18, 30, 0, 0); + return { + user: mockUser._id, + name: `Séance ${i + 1}`, + status: 'finished', + date, + durationSeconds: 3300, + }; + }), + ); + + // Records d'exercices rattachés à la première séance (workout requis + // par le schéma) — alimentent le classement par exercice + if (spec.records.length > 0 && workouts.length > 0) { + await ExerciseRecord.insertMany( + spec.records.map((r) => ({ + user: mockUser._id, + workout: workouts[0]._id, + exerciceNom: r.exercice, + series: r.series, + })), + ); + } + await Friendship.create( spec.status === 'pending' ? { requester: mockUser._id, recipient: myId, status: 'pending' } - : { requester: myId, recipient: mockUser._id, status: 'accepted' }, + : { requester: myId, recipient: mockUser._id, status: 'accepted', friendshipXp: spec.friendshipXp, friendshipLevel: spec.friendshipLevel }, ); - created.push({ pseudo: mockUser.pseudo, level, rank: mockUser.rank, status: spec.status }); + // Amitié niveau 5 créée directement (hors addFriendshipXp) : on réplique + // manuellement son effet de bord pour l'appelant — cadre unique + trophée. + if (spec.status === 'accepted' && spec.friendshipLevel === 5) { + await addUniqueItemOnce(myId, 'PROFILE_FRAME_BLOOD_BOND', 'unique'); + await checkAndUnlockAchievements(String(myId)); + } + + created.push({ + pseudo: mockUser.pseudo, + level: spec.level, + rank: mockUser.rank, + status: spec.status, + workouts: spec.workoutDays.length, + records: spec.records.length, + trophies: spec.achievements.length, + }); } return res.status(201).json({ success: true, - message: `${created.length} faux profils générés (2 amis acceptés, 1 demande en attente).`, + message: `${created.length} faux profils complets générés (cadres, trophées, vitrines, séances, records).`, created, }); } catch (err) { diff --git a/back/controllers/exercise.controller.js b/back/controllers/exercise.controller.js index 753098a..5586e49 100644 --- a/back/controllers/exercise.controller.js +++ b/back/controllers/exercise.controller.js @@ -56,4 +56,78 @@ exports.getWorkoutRecords = async (req, res, next) => { } catch (error) { next(error); } -}; \ No newline at end of file +}; +/** + * CLASSEMENT PAR EXERCICE (RÉSEAU D'AMIS) + * GET /api/exercises/leaderboard?exercise=Développé couché + * + * Renvoie, pour un exercice donné, le meilleur poids soulevé par chaque + * membre du réseau (moi + amis acceptés), trié décroissant. Le matching du + * nom est insensible à la casse et aux accents (collation fr, strength 1) + * pour tolérer les variations de saisie entre appareils. + */ +exports.getExerciseLeaderboard = async (req, res, next) => { + try { + const mongoose = require("mongoose"); + const Friendship = require("../models/Friendship"); + const User = require("../models/User"); + const ExerciseRecord = require("../models/ExerciseRecord"); + + const myId = req.user.id; + const exercise = typeof req.query.exercise === "string" ? req.query.exercise.trim() : ""; + + if (exercise.length < 2) { + const err = new Error("Le paramètre 'exercise' est obligatoire (2 caractères minimum)."); + err.statusCode = 400; + return next(err); + } + + // Réseau : moi + amis acceptés uniquement — pas de fuite hors du cercle social + const friendships = await Friendship.find({ + $or: [{ requester: myId }, { recipient: myId }], + status: "accepted", + }); + const networkIds = [ + new mongoose.Types.ObjectId(myId), + ...friendships.map((f) => + f.requester.toString() === myId ? f.recipient : f.requester, + ), + ]; + + const rows = await ExerciseRecord.aggregate([ + { $match: { user: { $in: networkIds }, exerciceNom: exercise } }, + { $unwind: "$series" }, + { $group: { + _id: "$user", + maxPoids: { $max: "$series.poids" }, + maxReps: { $max: "$series.repetitions" }, + } }, + { $sort: { maxPoids: -1 } }, + { $limit: 20 }, + ]).collation({ locale: "fr", strength: 1 }); + + // Enrichissement avec les champs publics (une seule requête) + const users = await User.find({ _id: { $in: rows.map((r) => r._id) } }) + .select("pseudo level rank"); + const userById = new Map(users.map((u) => [u._id.toString(), u])); + + const leaderboard = rows + .filter((r) => userById.has(r._id.toString())) + .map((r, index) => ({ + position: index + 1, + user: userById.get(r._id.toString()), + maxPoids: r.maxPoids, + maxReps: r.maxReps, + isMe: r._id.toString() === myId, + })); + + return res.status(200).json({ + success: true, + exercise, + count: leaderboard.length, + leaderboard, + }); + } catch (error) { + next(error); + } +}; diff --git a/back/controllers/friend.controller.js b/back/controllers/friend.controller.js index 7530ac1..2b44bc1 100644 --- a/back/controllers/friend.controller.js +++ b/back/controllers/friend.controller.js @@ -5,7 +5,14 @@ const Friendship = require('../models/Friendship'); const User = require('../models/User'); const Workout = require('../models/Workout'); const ExerciseRecord = require('../models/ExerciseRecord'); -const { ACHIEVEMENT_CATALOG, CATALOG_SIZE } = require('./reward.controller'); +const { ACHIEVEMENT_CATALOG } = require('./reward.controller'); +const { LOCAL_TROPHY_CATALOG } = require('../data/localTrophyCatalog'); + +// Catalogue UNIFIÉ : 9 trophées backend (évalués serveur) + 41 trophées locaux +// (évalués client, synchronisés via /rewards/achievements/sync). C'est la +// totalité de ce qu'un ami peut voir sur un profil public. +const FULL_ACHIEVEMENT_CATALOG = { ...ACHIEVEMENT_CATALOG, ...LOCAL_TROPHY_CATALOG }; +const FULL_CATALOG_SIZE = Object.keys(FULL_ACHIEVEMENT_CATALOG).length; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -63,7 +70,7 @@ function computeStreakFromDates(dates) { function buildAchievementsView(userAchievements) { const unlockedMap = new Map(userAchievements.map((a) => [a.achievementId, a.unlockedAt])); - const achievements = Object.values(ACHIEVEMENT_CATALOG).map((entry) => { + const achievements = Object.values(FULL_ACHIEVEMENT_CATALOG).map((entry) => { const unlocked = unlockedMap.has(entry.id); const unlockedAt = unlockedMap.get(entry.id) ?? null; @@ -81,9 +88,9 @@ function buildAchievementsView(userAchievements) { return { achievements, stats: { - total: CATALOG_SIZE, + total: FULL_CATALOG_SIZE, unlocked: unlockedCount, - percentage: Math.round((unlockedCount / CATALOG_SIZE) * 100), + percentage: Math.round((unlockedCount / FULL_CATALOG_SIZE) * 100), }, }; } @@ -410,7 +417,7 @@ exports.getFriendProfile = async (req, res, next) => { } const friend = await User.findById(friendId) - .select('pseudo level rank xp achievements streakGels totalWorkoutMinutes equippedFrame createdAt'); + .select('pseudo level rank xp achievements showcasedAchievements streakGels totalWorkoutMinutes equippedFrame createdAt'); if (!friend) return next(createError('Utilisateur introuvable.', 404)); const friendObjectId = new mongoose.Types.ObjectId(friendId); @@ -452,6 +459,11 @@ exports.getFriendProfile = async (req, res, next) => { }, achievements, achievementsStats, + // Vitrine : IDs mis en avant, restreints aux trophées réellement + // débloqués (au cas où un trophée aurait été retiré/désynchronisé) + showcasedAchievements: (friend.showcasedAchievements || []).filter((id) => + friend.achievements.some((a) => a.achievementId === id), + ), records: records.map((r) => ({ exercice: r._id, maxPoids: r.maxPoids, diff --git a/back/controllers/reward.controller.js b/back/controllers/reward.controller.js index 04c4491..7b558bb 100644 --- a/back/controllers/reward.controller.js +++ b/back/controllers/reward.controller.js @@ -389,6 +389,57 @@ exports.checkAchievements = async (req, res, next) => { } }; +// ───────────────────────────────────────────────────────────────────────────── +// syncLocalAchievements PUT /api/rewards/achievements/sync +// ───────────────────────────────────────────────────────────────────────────── + +const { LOCAL_TROPHY_IDS } = require('../data/localTrophyCatalog'); + +/** + * Synchronise les trophées LOCAUX débloqués par le client (catalogue + * front/src/data/trophyCatalog.js, évalué sur les logs AsyncStorage que le + * serveur ne possède pas) vers le tableau `achievements` du User. + * + * Règles : + * - Additif uniquement : un trophée synchronisé n'est jamais retiré + * (même sémantique irréversible que les trophées backend). + * - Allowlist stricte : tout ID hors catalogue local est ignoré — impossible + * d'injecter des IDs arbitraires ou des trophées backend (BIRTHDAY_SET…) + * qui, eux, ne se débloquent que par la logique serveur. + * - Écriture par $push gardé ($ne) : idempotent et sans doublon même en + * cas d'appels concurrents. + */ +exports.syncLocalAchievements = async (req, res, next) => { + try { + const myId = req.user.id; + const { ids } = req.body; + + if (!Array.isArray(ids)) { + return next(createError('ids doit être un tableau.', 400)); + } + + const validIds = [...new Set(ids.filter((id) => typeof id === 'string' && LOCAL_TROPHY_IDS.has(id)))]; + + let added = 0; + for (const achievementId of validIds) { + const result = await User.updateOne( + { _id: myId, 'achievements.achievementId': { $ne: achievementId } }, + { $push: { achievements: { achievementId, unlockedAt: new Date() } } }, + ); + if (result.modifiedCount > 0) added += 1; + } + + return res.status(200).json({ + success: true, + synced: validIds.length, + added, + ignored: ids.length - validIds.length, + }); + } catch (err) { + next(err); + } +}; + // Exporté pour être importé depuis d'autres contrôleurs (openChest, acceptFriend…) exports.checkAndUnlockAchievements = checkAndUnlockAchievements; exports.ACHIEVEMENT_CATALOG = ACHIEVEMENT_CATALOG; diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index c861511..a21d874 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -48,6 +48,26 @@ exports.updateMe = async (req, res, next) => { } }; +/** + * METTRE À JOUR LA VITRINE DE TROPHÉES + * Synchronise la sélection de trophées mis en avant (max 3) pour qu'elle soit + * visible sur le profil public. Les IDs hors catalogue unifié sont rejetés. + */ +exports.updateShowcase = async (req, res, next) => { + try { + const { achievementIds } = req.body; + const user = await userService.updateShowcase(req.user.id, achievementIds); + + res.status(200).json({ + success: true, + message: "Vitrine mise à jour avec succès", + showcasedAchievements: user.showcasedAchievements, + }); + } catch (error) { + next(error); + } +}; + /** * METTRE À JOUR LE CADRE DE PROFIL ÉQUIPÉ * Synchronise le choix de cadre (forme + couleur) fait localement diff --git a/back/data/localTrophyCatalog.js b/back/data/localTrophyCatalog.js new file mode 100644 index 0000000..1b7bbb4 --- /dev/null +++ b/back/data/localTrophyCatalog.js @@ -0,0 +1,166 @@ +'use strict'; + +// ─── Miroir backend du catalogue de trophées LOCAL du front ─────────────────── +// Source de vérité des CONDITIONS : front/src/data/trophyCatalog.js — les +// conditions s'évaluent côté client (elles dépendent des logs de séances +// AsyncStorage que le serveur ne possède pas). Le client synchronise les IDs +// débloqués via PUT /api/rewards/achievements/sync ; ce fichier ne porte que +// les MÉTADONNÉES d'affichage — mêmes noms de champs que le front +// (label/condition/epicDesc/gradientColors/tier) pour un rendu visuel +// PARFAITEMENT identique (TrophySlot est partagé, pas réimplémenté) — et sert +// d'allowlist pour la synchronisation. +// +// hidden: true → catégorie "secret" du front : masqué tant que non débloqué. + +const T = (id, category, icon, label, condition, epicDesc, color, gradientColors, tier, hidden = false) => + ({ id, category, icon, label, condition, epicDesc, color, gradientColors, tier, hidden }); + +const LOCAL_TROPHY_LIST = [ + // ── HÉRITAGE (6) ── + T('ignition', 'heritage', 'flame', 'Ignition', '1ère séance', + "La flamme s'allume. Votre premier pas dans l'arène — et rien ne sera jamais plus pareil.", + '#FE7439', ['#FF9A5C', '#FE7439', '#C44A10'], 'bronze'), + T('promise', 'heritage', 'medal', 'Promesse', 'Niveau 10', + "Le novice est mort. Vous avez prouvé que vous êtes là pour durer — la promesse est tenue.", + '#FFD700', ['#FFE566', '#FFD700', '#B8860B'], 'gold'), + T('apprenti', 'heritage', 'school', 'Apprenti', 'Niveau 25', + "Les bases sont posées. Chaque set vous a sculpté — vous n'êtes plus un débutant, vous êtes un apprenti.", + '#34D399', ['#6EE7B7', '#34D399', '#059669'], 'silver'), + T('centurion', 'heritage', 'trophy', 'Centurion', '50 séances', + "Votre volonté est d'acier. 50 combats menés avec honneur — les légions vous saluent.", + '#6E6AF0', ['#9B97FF', '#6E6AF0', '#3D3A9E'], 'platinum'), + T('veteran', 'heritage', 'shield-checkmark', 'Vétéran', 'Niveau 75', + "Trois quarts du chemin vers le sommet. Vous portez les cicatrices de centaines de batailles.", + '#3B82F6', ['#60A5FA', '#3B82F6', '#1D4ED8'], 'gold'), + T('demi_dieu', 'heritage', 'sparkles', 'Demi-Dieu', 'Niveau 150', + "150 niveaux d'ascension. Les mortels vous regardent avec crainte. Vous n'appartenez plus à leur monde.", + '#8B5CF6', ['#A78BFA', '#8B5CF6', '#5B21B6'], 'diamond'), + + // ── FORCE (7) ── + T('titan', 'force', 'barbell', 'Le Titan', '10 000 kg soulevés', + "Dix mille kilos. Un chiffre qui n'appartient qu'aux légendes du fer.", + '#DC2626', ['#EF4444', '#DC2626', '#991B1B'], 'gold'), + T('iron_will', 'force', 'shield', 'Iron Will', '3 jours consécutifs', + "Trois jours sans relâche. Quand votre corps criait stop, vous avez choisi de continuer.", + '#B45309', ['#D97706', '#B45309', '#78350F'], 'silver'), + T('machine', 'force', 'flash', 'La Machine', '25 sets en une séance', + "Vingt-cinq séries en un seul souffle. Vous n'êtes pas humain, vous êtes une machine.", + '#F59E0B', ['#FDE68A', '#F59E0B', '#B45309'], 'silver'), + T('legionnaire', 'force', 'star', 'Légionnaire', '100 séances', + "Cent batailles. Cent victoires sur vous-même. Les légions de Rome vous auraient honoré.", + '#EAB308', ['#FDE047', '#EAB308', '#854D0E'], 'platinum'), + T('colossus', 'force', 'body', 'Colosse', '50 000 kg soulevés', + "Cinquante mille kilos déplacés par votre seule volonté. Vous êtes une force de la nature.", + '#991B1B', ['#DC2626', '#991B1B', '#450A0A'], 'diamond'), + T('forge', 'force', 'hammer', 'La Forge', '500 sets au total', + "Cinq cents séries. Votre corps a été forgé à la chaleur du travail acharné.", + '#C2410C', ['#EA580C', '#C2410C', '#7C2D12'], 'silver'), + T('volume_king', 'force', 'analytics', 'Roi du Volume', '100 000 kg soulevés', + "Cent mille kilos. Le roi du volume tient son trône.", + '#7C3AED', ['#8B5CF6', '#7C3AED', '#4C1D95'], 'diamond'), + + // ── EXPLORATION (6) ── + T('polyvalent', 'exploration', 'grid', 'Polyvalent', '5 groupes musculaires', + "Pecs, dos, jambes, épaules, bras — vous ne laissez aucun muscle au repos.", + '#22C55E', ['#4ADE80', '#22C55E', '#15803D'], 'silver'), + T('marathonien', 'exploration', 'time', 'Marathonien', 'Séance ≥ 60 min', + "Une heure dans l'arène. Quand les autres partaient après 30 minutes, vous étiez encore là.", + '#0EA5E9', ['#38BDF8', '#0EA5E9', '#0369A1'], 'bronze'), + T('demi_legende', 'exploration', 'rocket', 'Demi-Légende', 'Niveau 50', + "La moitié du chemin vers le sommet. Peu y arrivent — vous y êtes.", + '#3B82F6', ['#60A5FA', '#3B82F6', '#1D4ED8'], 'gold'), + T('xp_millionaire', 'exploration', 'infinite', 'XP Millionnaire', '1 000 000 XP cumulés', + "Un million de points d'expérience. Une vie entière de sueur, de fer et de détermination.", + '#A855F7', ['#C084FC', '#A855F7', '#7E22CE'], 'diamond'), + T('speed_demon', 'exploration', 'speedometer', 'Speed Demon', 'Séance complète ≤ 20 min', + "Vingt minutes. Efficace, explosif, précis. Quand les autres finissent leur échauffement, vous êtes déjà sous la douche.", + '#F97316', ['#FB923C', '#F97316', '#C2410C'], 'bronze'), + T('ultra_marathon', 'exploration', 'hourglass', 'Ultra-Marathon', 'Séance ≥ 90 min', + "Quatre-vingt-dix minutes de pur effort. Quand la plupart abandonnent, vous n'avez pas encore commencé.", + '#0891B2', ['#22D3EE', '#0891B2', '#164E63'], 'silver'), + + // ── SECRET (8) — masqués tant que non débloqués ── + T('night_owl', 'secret', 'moon', 'Oiseau de Nuit', 'Séance entre 23h et 4h', + "Pendant que le monde dort, vous forgez votre corps dans le silence.", + '#6366F1', ['#818CF8', '#6366F1', '#3730A3'], 'silver', true), + T('determined', 'secret', 'calendar', 'Déterminé', 'Séance le 1er janvier', + "Quand les autres font des vœux, vous faites des sets.", + '#F43F5E', ['#FB7185', '#F43F5E', '#BE123C'], 'gold', true), + T('early_bird', 'secret', 'sunny', 'Lève-Tôt', 'Séance avant 6h du matin', + "Le soleil n'est pas encore levé, et vous êtes déjà en sueur.", + '#F97316', ['#FB923C', '#F97316', '#C2410C'], 'bronze', true), + T('dawn_warrior', 'secret', 'partly-sunny', "Guerrier de l'Aube", 'Séance avant 5h du matin', + "4h58. L'obscurité n'a pas encore capitulé, mais vous, oui.", + '#F59E0B', ['#FCD34D', '#F59E0B', '#92400E'], 'silver', true), + T('streak_hunter', 'secret', 'flame', 'Chasseur de Streak', 'Streak ≥ 7 jours', + "Sept jours sans interruption. Le feu de votre volonté brûle plus fort que jamais.", + '#FB923C', ['#FDBA74', '#FB923C', '#EA580C'], 'silver', true), + T('ultra_streak', 'secret', 'infinite', 'Ultra Streak', 'Streak ≥ 30 jours', + "Trente jours de constance absolue. Vous avez transcendé la discipline pour atteindre l'obsession.", + '#FFD700', ['#FDE68A', '#FFD700', '#B45309'], 'diamond', true), + T('midnight_wolf', 'secret', 'cloudy-night', 'Le Loup', 'Séance entre 0h et 1h', + "Minuit passé. Les loups chassent quand le troupeau dort.", + '#4338CA', ['#6366F1', '#4338CA', '#1E1B4B'], 'silver', true), + T('athly_god', 'secret', 'planet', 'ATHLY GOD', 'Niveau 200', + "Le sommet absolu. Vous n'êtes plus un athlète — vous êtes une légende vivante. Le trône vous appartient.", + '#FFD700', ['#FDE68A', '#FFD700', '#92400E'], 'diamond', true), + + // ── CORPS (5) ── + T('corps_bronze', 'corps', 'fitness', 'Initié Poids Corps', '50 sets complétés', + "Cinquante séries avec votre propre corps. Pas de barres, pas de charges — juste vous contre la gravité.", + '#CD7F32', ['#E8A060', '#CD7F32', '#6B3A1A'], 'bronze'), + T('corps_silver', 'corps', 'walk', 'Guerrier Poids Corps', '150 sets complétés', + "Cent cinquante séries. Votre poids corporel est devenu votre outil de sculpture le plus précis.", + '#D1D5DB', ['#F3F4F6', '#D1D5DB', '#9CA3AF'], 'silver'), + T('corps_gold', 'corps', 'barbell', 'Maître Poids Corps', '400 sets complétés', + "Quatre cents séries. La maîtrise s'est installée.", + '#FFD700', ['#FDE047', '#FFD700', '#B45309'], 'gold'), + T('corps_platinum', 'corps', 'shield-half', 'Élite Poids Corps', '800 sets complétés', + "Huit cents séries. Vous portez votre corps comme une armure.", + '#E5E7EB', ['#FFFFFF', '#E5E7EB', '#9CA3AF'], 'platinum'), + T('corps_diamond', 'corps', 'diamond', 'Légende Poids Corps', '1 500 sets complétés', + "Quinze cents séries. Un monument de discipline et de force pure.", + '#60A5FA', ['#BFDBFE', '#60A5FA', '#1D4ED8'], 'diamond'), + + // ── RÉGULARITÉ (5) ── + T('reg_3m', 'regularite', 'calendar-outline', 'Constance 3 Mois', 'Séances sur 3 mois', + "Trois mois d'entraînement. Pas une mode — une véritable habitude.", + '#10B981', ['#34D399', '#10B981', '#065F46'], 'bronze'), + T('reg_6m', 'regularite', 'time-outline', 'Constance 6 Mois', 'Séances sur 6 mois', + "Six mois. La moitié d'une année dédiée au progrès.", + '#0D9488', ['#14B8A6', '#0D9488', '#134E4A'], 'silver'), + T('reg_9m', 'regularite', 'medal-outline', 'Constance 9 Mois', 'Séances sur 9 mois', + "Neuf mois d'engagement total. Il faut neuf mois pour renaître.", + '#0891B2', ['#22D3EE', '#0891B2', '#0C4A6E'], 'gold'), + T('reg_12m', 'regularite', 'trophy-outline', 'Constance 12 Mois', 'Séances sur 12 mois', + "Un an. 365 jours de transformation. Vous êtes encore debout.", + '#7C3AED', ['#A78BFA', '#7C3AED', '#3B0764'], 'platinum'), + T('iron_discipline', 'regularite', 'repeat', 'Discipline de Fer', '5 semaines avec 1+ séance', + "Cinq semaines consécutives sans interruption.", + '#6366F1', ['#818CF8', '#6366F1', '#312E81'], 'silver'), + + // ── SOCIAL (2) ── + T('first_friend', 'social', 'people', 'Premier Ami', 'Ajouter un ami', + "Les grandes épopées ne se vivent pas seules. Votre premier compagnon d'armes vous attend.", + '#EC4899', ['#F472B6', '#EC4899', '#9D174D'], 'bronze'), + T('mentor', 'social', 'people-circle', 'Mentor', 'Inspirer 5 amis', + "Vous avez allumé la flamme chez cinq autres — vous êtes plus qu'un athlète, vous êtes un mentor.", + '#8B5CF6', ['#A78BFA', '#8B5CF6', '#4C1D95'], 'gold'), + + // ── SPÉCIAL (1) ── + T('athly_birthday', 'special', 'gift', 'Anniversaire Athly', 'Séance le 13 mai', + "Le jour où Athly est né, vous étiez là — à suer, à pousser, à vous dépasser.", + '#FE7439', ['#FF9A5C', '#FE7439', '#C44A10'], 'gold'), + + // ── ULTIME (1) ── + T('souverain_absolu', 'ultime', 'infinite', 'Souverain Absolu', 'Tous les trophées débloqués', + "Il n'existe pas de plus grand accomplissement. Vous avez tout conquis, tout maîtrisé, tout surpassé. L'empire d'Athly vous appartient — et l'univers entier s'incline devant vous.", + '#FFD700', ['#FFFACD', '#FFD700', '#FF8C00', '#C44A10'], 'diamond'), +]; + +// Indexé par id, même forme que ACHIEVEMENT_CATALOG (reward.controller.js) +const LOCAL_TROPHY_CATALOG = Object.fromEntries(LOCAL_TROPHY_LIST.map((t) => [t.id, t])); + +const LOCAL_TROPHY_IDS = new Set(Object.keys(LOCAL_TROPHY_CATALOG)); + +module.exports = { LOCAL_TROPHY_CATALOG, LOCAL_TROPHY_IDS }; diff --git a/back/models/User.js b/back/models/User.js index a9417dd..9b9bed6 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -129,6 +129,11 @@ const UserSchema = new mongoose.Schema( // Tableau des trophées débloqués. Le catalogue complet vit dans reward.controller.js. achievements: { type: [AchievementEntrySchema], default: [] }, + // ── Vitrine de trophées ─────────────────────────────────────────────────── + // IDs (catalogue unifié backend+local) que l'utilisateur met en avant sur + // son profil public. Max 3 — validé par la route PUT /users/me/showcase. + showcasedAchievements: { type: [String], default: [] }, + // ── Cadre de profil équipé ───────────────────────────────────────────────── // Synchronisé depuis le choix local (useAvatarFrame.js) pour que les amis // voient le même cadre sur le profil public. shapeId/colorId sont des clés diff --git a/back/routes/exercise.routes.js b/back/routes/exercise.routes.js index a9e6164..32e20e6 100644 --- a/back/routes/exercise.routes.js +++ b/back/routes/exercise.routes.js @@ -13,6 +13,9 @@ const { exerciseRecordSchema } = require("../validators/exercise.validator"); // Ajouter une nouvelle performance (ex: 3 séries de Squat) router.post("/", auth, validate(exerciseRecordSchema), exerciseController.addRecord); +// Classement par exercice au sein du réseau d'amis (?exercise=Nom) +router.get("/leaderboard", auth, exerciseController.getExerciseLeaderboard); + // Obtenir l'historique de progression d'un exercice précis par son nom router.get("/history/:name", auth, exerciseController.getExerciseHistory); diff --git a/back/routes/reward.routes.js b/back/routes/reward.routes.js index 2df2825..a4c094e 100644 --- a/back/routes/reward.routes.js +++ b/back/routes/reward.routes.js @@ -14,7 +14,11 @@ router.post('/birthdate', reward.setBirthdate); router.post('/birthday/check', reward.checkBirthday); // ── Trophées / Succès ───────────────────────────────────────────────────────── -router.get('/achievements', reward.getUserAchievements); +router.get('/achievements', reward.getUserAchievements); + +// Synchronisation des trophées locaux (évalués côté client) vers la BDD — +// additive, allowlistée sur le catalogue local (data/localTrophyCatalog.js) +router.put('/achievements/sync', reward.syncLocalAchievements); // Vérification manuelle — à appeler aussi depuis les autres contrôleurs // (openChest, acceptFriendRequest, checkAndUpdateGroupStreaks…) diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 824a601..593ff33 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const userController = require("../controllers/user.controller"); const auth = require("../middleware/auth.middleware"); const validate = require("../middleware/validate.middleware"); -const { updateProfile, updateFrame } = require("../validators/user.validator"); +const { updateProfile, updateFrame, updateShowcase } = require("../validators/user.validator"); /** * ROUTES UTILISATEURS PROTEGEES @@ -19,6 +19,9 @@ router.put("/me", auth, validate(updateProfile), userController.updateMe); // Synchroniser le cadre de profil équipé (visible sur le profil public) router.put("/me/frame", auth, validate(updateFrame), userController.updateFrame); +// Synchroniser la vitrine de trophées (max 3, visible sur le profil public) +router.put("/me/showcase", auth, validate(updateShowcase), userController.updateShowcase); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/auth.service.js b/back/services/auth.service.js index 0b144a7..daee257 100644 --- a/back/services/auth.service.js +++ b/back/services/auth.service.js @@ -1,8 +1,11 @@ +const crypto = require("crypto"); const User = require("../models/User"); +const Friendship = require("../models/Friendship"); const bcrypt = require("bcrypt"); const jwt = require("jsonwebtoken"); const config = require("../config/env"); const emailService = require("./email.service"); +const { addItemAtomic } = require("./inventory.service"); const MAX_OTP_ATTEMPTS = 5; const CODE_TTL_VERIFY = 10 * 60 * 1000; // 10 min @@ -15,6 +18,27 @@ function generateCode() { return Math.floor(100000 + Math.random() * 900000).toString(); } +// Code de parrainage lisible : ATH-XXXXX (sans 0/O/1/I ambigus). +// L'unicité est garantie par l'index unique+sparse du modèle : en cas de +// collision (improbable, 33^5 combinaisons), on retire. +const REFERRAL_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +function generateReferralCode() { + let suffix = ""; + const bytes = crypto.randomBytes(5); + for (let i = 0; i < 5; i++) suffix += REFERRAL_ALPHABET[bytes[i] % REFERRAL_ALPHABET.length]; + return `ATH-${suffix}`; +} + +async function uniqueReferralCode() { + for (let attempt = 0; attempt < 5; attempt++) { + const code = generateReferralCode(); + const taken = await User.exists({ referralCode: code }); + if (!taken) return code; + } + // Repli quasi-impossible à atteindre : suffixe horodaté forcément unique + return `ATH-${Date.now().toString(36).toUpperCase().slice(-6)}`; +} + function makeToken(userId) { return jwt.sign({ id: userId }, config.jwtSecret, { expiresIn: config.jwtExpires }); } @@ -31,14 +55,33 @@ function httpError(message, statusCode, code) { class AuthService { // ── Inscription ──────────────────────────────────────────────────────────── - async register(pseudo, email, password) { + /** + * Crée le compte. Si un `referralCode` (optionnel) est fourni : + * - Il doit être valide, sinon 400 REFERRAL_INVALID (l'utilisateur peut + * corriger sa saisie ou vider le champ — le compte n'est PAS créé). + * - Le filleul reçoit ses récompenses de bienvenue dès la création + * (1 STREAK_FREEZE + 1 LEVEL_COUPON), le parrain les mêmes + le trophée + * FIRST_REFERRAL, et les deux sont liés en amis "accepted" d'office. + */ + async register(pseudo, email, password, referralCode = null) { const existing = await User.findOne({ email }); if (existing) throw httpError("Un utilisateur avec cet email existe déjà.", 409, "EMAIL_TAKEN"); + // ── Résolution du parrain AVANT création : un code invalide ne doit pas + // laisser un compte à moitié parrainé en base ──────────────────────── + let referrer = null; + const cleanCode = typeof referralCode === "string" ? referralCode.trim().toUpperCase() : ""; + if (cleanCode) { + referrer = await User.findOne({ referralCode: cleanCode }).select("_id"); + if (!referrer) { + throw httpError("Code de parrainage invalide.", 400, "REFERRAL_INVALID"); + } + } + const hashedPassword = await bcrypt.hash(password, BCRYPT_ROUNDS); const verificationCode = generateCode(); - await User.create({ + const newUser = await User.create({ pseudo, name: pseudo, // rétrocompatibilité email, @@ -47,14 +90,49 @@ class AuthService { verificationCode, codeExpires: new Date(Date.now() + CODE_TTL_VERIFY), verifyAttempts: 0, + referralCode: await uniqueReferralCode(), + ...(referrer && { + referredBy: referrer._id, + // Récompenses de bienvenue du filleul, directement à la création + inventory: [ + { itemType: "STREAK_FREEZE", rarity: "rare", quantity: 1 }, + { itemType: "LEVEL_COUPON", rarity: "legendary", quantity: 1 }, + ], + }), }); + // ── Effets côté parrain (best-effort : ne bloque jamais l'inscription) ── + if (referrer) { + try { + await addItemAtomic(referrer._id, "STREAK_FREEZE", "rare", 1); + await addItemAtomic(referrer._id, "LEVEL_COUPON", "legendary", 1); + + // Liés en amis d'office — le parrainage EST la preuve de la relation + await Friendship.create({ + requester: referrer._id, + recipient: newUser._id, + status: "accepted", + }); + + // Trophée FIRST_REFERRAL du parrain (require tardif : évite le cycle + // auth.service → reward.controller → … au chargement des modules) + const { checkAndUnlockAchievements } = require("../controllers/reward.controller"); + await checkAndUnlockAchievements(referrer._id.toString()); + } catch (err) { + console.error("⚠️ [register] Récompenses de parrainage partielles :", err.message); + } + } + // Envoi non-bloquant : un échec SMTP ne plante pas la réponse emailService.sendVerificationEmail(email, verificationCode).catch(err => console.error("❌ Email de vérification :", err.message) ); - return { message: "Compte créé. Vérifiez votre email.", email }; + return { + message: "Compte créé. Vérifiez votre email.", + email, + referred: Boolean(referrer), + }; } // ── Connexion ────────────────────────────────────────────────────────────── @@ -245,3 +323,5 @@ class AuthService { } module.exports = new AuthService(); +// Réutilisé par user.service (génération lazy pour les comptes existants) +module.exports.uniqueReferralCode = uniqueReferralCode; diff --git a/back/services/user.service.js b/back/services/user.service.js index 9355c53..b3ae31c 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -15,6 +15,15 @@ class UserService { // .select("-password") permet d'exclure le champ mot de passe par sécurité const user = await User.findById(userId).select("-password"); if (!user) throw new Error("Utilisateur non trouvé."); + + // Génération lazy pour les comptes antérieurs au parrainage à l'inscription : + // tout utilisateur qui consulte son profil obtient son code une fois pour toutes. + if (!user.referralCode) { + const { uniqueReferralCode } = require("./auth.service"); + user.referralCode = await uniqueReferralCode(); + await user.save(); + } + return user; } @@ -51,6 +60,30 @@ class UserService { console.log(`🗑️ [deleteAccount] Utilisateur supprimé : ${userId}`); } + /** + * Met à jour atomiquement la vitrine de trophées (max 3 IDs). + * Les IDs sont filtrés sur le catalogue unifié (backend + local) : aucune + * valeur arbitraire ne peut être stockée puis affichée chez un ami. + * @param {string} userId + * @param {string[]} achievementIds + */ + async updateShowcase(userId, achievementIds) { + const { ACHIEVEMENT_CATALOG } = require('../controllers/reward.controller'); + const { LOCAL_TROPHY_CATALOG } = require('../data/localTrophyCatalog'); + + const validIds = [...new Set(achievementIds)] + .filter((id) => ACHIEVEMENT_CATALOG[id] || LOCAL_TROPHY_CATALOG[id]) + .slice(0, 3); + + const updatedUser = await User.findByIdAndUpdate( + userId, + { $set: { showcasedAchievements: validIds } }, + { new: true, runValidators: true }, + ).select('showcasedAchievements'); + if (!updatedUser) throw new Error('Utilisateur non trouvé.'); + return updatedUser; + } + /** * Met à jour atomiquement le cadre de profil équipé (forme + couleur). * @param {string} userId diff --git a/back/tests/debug.test.js b/back/tests/debug.test.js index 2337c51..6cf19e8 100644 --- a/back/tests/debug.test.js +++ b/back/tests/debug.test.js @@ -212,18 +212,18 @@ describe('POST /api/debug/godmode/mock-social — outil dev (génère un faux r alice = await createAndLoginUser('AliceFit', 'alice@athly.fr'); }); - it('✅ Génère 2 amis acceptés + 1 demande en attente reçue', async () => { + it('✅ Génère 3 amis acceptés + 1 demande en attente reçue', async () => { const res = await request(app) .post('/api/debug/godmode/mock-social') .set('Authorization', `Bearer ${alice.token}`); expect(res.statusCode).toBe(201); - expect(res.body.created).toHaveLength(3); + expect(res.body.created).toHaveLength(4); const friendsRes = await request(app) .get('/api/friends/list') .set('Authorization', `Bearer ${alice.token}`); - expect(friendsRes.body.friends).toHaveLength(2); + expect(friendsRes.body.friends).toHaveLength(3); expect(friendsRes.body.friends.every((f) => f.user.pseudo.startsWith('FauxAmi_'))).toBe(true); const pendingRes = await request(app) @@ -242,8 +242,8 @@ describe('POST /api/debug/godmode/mock-social — outil dev (génère un faux r .get('/api/friends/leaderboard') .set('Authorization', `Bearer ${alice.token}`); - // Alice + 2 amis acceptés (le 3e est encore "pending", pas dans le classement) - expect(res.body.count).toBe(3); + // Alice + 3 amis acceptés (le 4e est encore "pending", pas dans le classement) + expect(res.body.count).toBe(4); }); it('🔁 Idempotent : rejouer l\'outil ne crée pas de doublons', async () => { @@ -255,12 +255,12 @@ describe('POST /api/debug/godmode/mock-social — outil dev (génère un faux r .set('Authorization', `Bearer ${alice.token}`); const allMocks = await User.find({ pseudo: /^FauxAmi_/ }); - expect(allMocks).toHaveLength(3); + expect(allMocks).toHaveLength(4); const friendsRes = await request(app) .get('/api/friends/list') .set('Authorization', `Bearer ${alice.token}`); - expect(friendsRes.body.friends).toHaveLength(2); + expect(friendsRes.body.friends).toHaveLength(3); }); it('❌ 401 sans token', async () => { diff --git a/back/tests/deepIntegration.test.js b/back/tests/deepIntegration.test.js new file mode 100644 index 0000000..7cad2b1 --- /dev/null +++ b/back/tests/deepIntegration.test.js @@ -0,0 +1,393 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const Friendship = require('../models/Friendship'); +const Workout = require('../models/Workout'); +const ExerciseRecord = require('../models/ExerciseRecord'); +const { LOCAL_TROPHY_CATALOG } = require('../data/localTrophyCatalog'); +const { CATALOG_SIZE } = require('../controllers/reward.controller'); + +const FULL_SIZE = CATALOG_SIZE + Object.keys(LOCAL_TROPHY_CATALOG).length; + +async function createAndLoginUser(pseudo, email, extra = {}) { + await request(app) + .post('/api/auth/register') + .send({ pseudo, email, password: 'Password123!', ...extra }); + + await User.updateOne({ email }, { isVerified: true }); + + const loginRes = await request(app) + .post('/api/auth/login') + .send({ email, password: 'Password123!' }); + + const user = await User.findOne({ email }).select('_id referralCode'); + return { token: loginRes.body.token, userId: user._id.toString(), referralCode: user.referralCode }; +} + +async function makeFriends(a, b) { + return Friendship.create({ requester: a, recipient: b, status: 'accepted' }); +} + +describe('Intégration profonde — trophées sync, vitrine, leaderboard exos, parrainage', () => { + let alice, bob, carol; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await Promise.all([ + User.deleteMany({}), Friendship.deleteMany({}), + Workout.deleteMany({}), ExerciseRecord.deleteMany({}), + ]); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await Promise.all([ + User.deleteMany({}), Friendship.deleteMany({}), + Workout.deleteMany({}), ExerciseRecord.deleteMany({}), + ]); + alice = await createAndLoginUser('AliceFit', 'alice@athly.fr'); + bob = await createAndLoginUser('BobMuscle', 'bob@athly.fr'); + carol = await createAndLoginUser('CarolGains','carol@athly.fr'); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 1. Synchronisation des trophées locaux + // ─────────────────────────────────────────────────────────────────────────── + describe('PUT /api/rewards/achievements/sync — syncLocalAchievements', () => { + + it('✅ Ajoute les trophées locaux valides, ignore les IDs inconnus ET les IDs backend', async () => { + const res = await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['ignition', 'titan', 'id_bidon', 'BIRTHDAY_SET'] }); + + expect(res.statusCode).toBe(200); + expect(res.body.synced).toBe(2); // ignition + titan + expect(res.body.added).toBe(2); + expect(res.body.ignored).toBe(2); // id_bidon + BIRTHDAY_SET (backend, non synchronisable) + + const user = await User.findById(alice.userId); + const ids = user.achievements.map((a) => a.achievementId); + expect(ids).toContain('ignition'); + expect(ids).toContain('titan'); + expect(ids).not.toContain('BIRTHDAY_SET'); + expect(ids).not.toContain('id_bidon'); + }); + + it('🔁 Idempotent : re-synchroniser les mêmes IDs ne crée aucun doublon', async () => { + await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['ignition'] }); + const res = await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['ignition'] }); + + expect(res.body.added).toBe(0); + + const user = await User.findById(alice.userId); + expect(user.achievements.filter((a) => a.achievementId === 'ignition')).toHaveLength(1); + }); + + it('❌ 400 si ids n\'est pas un tableau', async () => { + const res = await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: 'ignition' }); + expect(res.statusCode).toBe(400); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 2. Catalogue unifié + vitrine sur le profil ami + // ─────────────────────────────────────────────────────────────────────────── + describe('Profil ami — catalogue complet et vitrine', () => { + + it(`✅ Le profil ami expose le catalogue UNIFIÉ (${FULL_SIZE} trophées)`, async () => { + await makeFriends(alice.userId, bob.userId); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.profile.achievements).toHaveLength(FULL_SIZE); + expect(res.body.profile.achievementsStats.total).toBe(FULL_SIZE); + + // Contient à la fois des trophées backend et locaux + const ids = res.body.profile.achievements.map((a) => a.id); + expect(ids).toContain('BIRTHDAY_CELEBRATED'); + expect(ids).toContain('ignition'); + expect(ids).toContain('souverain_absolu'); + }); + + it('✅ Un trophée local synchronisé apparaît débloqué chez l\'ami', async () => { + await makeFriends(alice.userId, bob.userId); + await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${bob.token}`) + .send({ ids: ['titan', 'night_owl'] }); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + const titan = res.body.profile.achievements.find((a) => a.id === 'titan'); + expect(titan.unlocked).toBe(true); + expect(titan.label).toBe('Le Titan'); + + // Trophée secret débloqué → révélé ; secret verrouillé → masqué + const nightOwl = res.body.profile.achievements.find((a) => a.id === 'night_owl'); + expect(nightOwl.unlocked).toBe(true); + expect(nightOwl.label).toBe('Oiseau de Nuit'); + const ultraStreak = res.body.profile.achievements.find((a) => a.id === 'ultra_streak'); + expect(ultraStreak.name).toBe('???'); + }); + + it('✅ PUT /users/me/showcase enregistre la vitrine (max 3, allowlist)', async () => { + const res = await request(app) + .put('/api/users/me/showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ achievementIds: ['ignition', 'titan', 'id_bidon'] }); + + expect(res.statusCode).toBe(200); + expect(res.body.showcasedAchievements).toEqual(['ignition', 'titan']); // id_bidon filtré + + const tooMany = await request(app) + .put('/api/users/me/showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ achievementIds: ['ignition', 'titan', 'promise', 'apprenti'] }); + expect(tooMany.statusCode).toBe(400); // Joi max 3 + }); + + it('✅ La vitrine de l\'ami est exposée, restreinte aux trophées débloqués', async () => { + await makeFriends(alice.userId, bob.userId); + + // Bob débloque ignition seulement, mais vitrine ignition + titan + await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${bob.token}`) + .send({ ids: ['ignition'] }); + await request(app) + .put('/api/users/me/showcase') + .set('Authorization', `Bearer ${bob.token}`) + .send({ achievementIds: ['ignition', 'titan'] }); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + // titan non débloqué → exclu de la vitrine visible + expect(res.body.profile.showcasedAchievements).toEqual(['ignition']); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 3. Classement par exercice + // ─────────────────────────────────────────────────────────────────────────── + describe('GET /api/exercises/leaderboard — classement par exercice', () => { + + async function seedRecord(userId, exercice, poids, repetitions) { + const workout = await Workout.create({ + user: userId, name: 'Séance', status: 'finished', date: new Date(), + }); + return ExerciseRecord.create({ + user: userId, workout: workout._id, exerciceNom: exercice, + series: [{ poids, repetitions }], + }); + } + + it('✅ Trie par poids décroissant, restreint au réseau d\'amis, marque isMe', async () => { + await makeFriends(alice.userId, bob.userId); + await seedRecord(alice.userId, 'Développé couché', 60, 8); + await seedRecord(bob.userId, 'Développé couché', 100, 5); + await seedRecord(carol.userId, 'Développé couché', 150, 3); // pas amie → exclue + + const res = await request(app) + .get('/api/exercises/leaderboard?exercise=' + encodeURIComponent('Développé couché')) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.count).toBe(2); + expect(res.body.leaderboard[0].user.pseudo).toBe('BobMuscle'); + expect(res.body.leaderboard[0].maxPoids).toBe(100); + expect(res.body.leaderboard[0].position).toBe(1); + expect(res.body.leaderboard[1].isMe).toBe(true); + expect(res.body.leaderboard[1].maxPoids).toBe(60); + }); + + it('✅ Matching insensible à la casse et aux accents', async () => { + await seedRecord(alice.userId, 'Développé couché', 80, 5); + + const res = await request(app) + .get('/api/exercises/leaderboard?exercise=' + encodeURIComponent('developpe couche')) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.leaderboard[0].maxPoids).toBe(80); + }); + + it('❌ 400 sans paramètre exercise', async () => { + const res = await request(app) + .get('/api/exercises/leaderboard') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).get('/api/exercises/leaderboard?exercise=Squat'); + expect(res.statusCode).toBe(401); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 4. Parrainage à l'inscription + // ─────────────────────────────────────────────────────────────────────────── + describe('POST /api/auth/register — parrainage intégré', () => { + + it('✅ Un referralCode est généré automatiquement à l\'inscription', async () => { + expect(alice.referralCode).toMatch(/^ATH-/); + // Unicité entre comptes + expect(alice.referralCode).not.toBe(bob.referralCode); + }); + + it('✅ Inscription avec code valide : récompenses des deux côtés + amitié acceptée', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ pseudo: 'Filleul', email: 'filleul@athly.fr', password: 'Password123!', referralCode: alice.referralCode }); + + expect(res.statusCode).toBe(201); + expect(res.body.referred).toBe(true); + + const filleul = await User.findOne({ email: 'filleul@athly.fr' }); + expect(filleul.referredBy.toString()).toBe(alice.userId); + expect(filleul.inventory.find((i) => i.itemType === 'STREAK_FREEZE')).toBeDefined(); + expect(filleul.inventory.find((i) => i.itemType === 'LEVEL_COUPON')).toBeDefined(); + + const referrer = await User.findById(alice.userId); + expect(referrer.inventory.find((i) => i.itemType === 'STREAK_FREEZE')).toBeDefined(); + expect(referrer.inventory.find((i) => i.itemType === 'LEVEL_COUPON')).toBeDefined(); + expect(referrer.achievements.find((a) => a.achievementId === 'FIRST_REFERRAL')).toBeDefined(); + + // Amitié auto, statut accepted + const friendship = await Friendship.findOne({ + requester: alice.userId, recipient: filleul._id, + }); + expect(friendship).not.toBeNull(); + expect(friendship.status).toBe('accepted'); + }); + + it('✅ Le code est insensible à la casse', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ pseudo: 'Filleul2', email: 'filleul2@athly.fr', password: 'Password123!', referralCode: alice.referralCode.toLowerCase() }); + expect(res.statusCode).toBe(201); + expect(res.body.referred).toBe(true); + }); + + it('❌ 400 si le code est invalide — le compte n\'est PAS créé', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ pseudo: 'Fraudeur', email: 'fraudeur@athly.fr', password: 'Password123!', referralCode: 'ATH-BIDON' }); + + expect(res.statusCode).toBe(400); + const ghost = await User.findOne({ email: 'fraudeur@athly.fr' }); + expect(ghost).toBeNull(); + }); + + it('✅ Champ vide toléré (pas de parrainage)', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ pseudo: 'Solo', email: 'solo@athly.fr', password: 'Password123!', referralCode: '' }); + expect(res.statusCode).toBe(201); + expect(res.body.referred).toBe(false); + }); + + it('✅ getMe génère un code aux comptes existants qui n\'en ont pas (lazy)', async () => { + await User.updateOne({ _id: alice.userId }, { $unset: { referralCode: 1 } }); + + const res = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.user.referralCode).toMatch(/^ATH-/); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 5. mockSocial dopé + // ─────────────────────────────────────────────────────────────────────────── + describe('POST /api/debug/godmode/mock-social — profils complets', () => { + + it('✅ Les faux amis ont cadres, trophées, vitrines, séances et records', async () => { + const res = await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(201); + + const mock1 = await User.findOne({ pseudo: 'FauxAmi_1' }); + expect(mock1.equippedFrame.shapeId).toBe('hexagon'); + expect(mock1.showcasedAchievements).toEqual(['promise', 'iron_will', 'marathonien']); + expect(mock1.achievements.length).toBeGreaterThanOrEqual(5); + expect(mock1.inventory.length).toBeGreaterThanOrEqual(2); + + const workouts = await Workout.find({ user: mock1._id }); + expect(workouts.length).toBe(6); + const records = await ExerciseRecord.find({ user: mock1._id }); + expect(records.length).toBe(3); + + // Le profil public reflète tout ça + const profileRes = await request(app) + .get(`/api/friends/profile/${mock1._id}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(profileRes.body.profile.showcasedAchievements).toEqual(['promise', 'iron_will', 'marathonien']); + expect(profileRes.body.profile.stats.totalSessions).toBe(6); + expect(profileRes.body.profile.records.length).toBeGreaterThan(0); + }); + + it('✅ Le classement par exercice fonctionne avec les mocks (Développé couché)', async () => { + await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + + const res = await request(app) + .get('/api/exercises/leaderboard?exercise=' + encodeURIComponent('Développé couché')) + .set('Authorization', `Bearer ${alice.token}`); + + // FauxAmi_1 (85kg), FauxAmi_2 (105kg) et FauxAmi_4 (90kg) sont amis acceptés ; FauxAmi_3 (pending) exclu + expect(res.body.count).toBe(3); + expect(res.body.leaderboard[0].user.pseudo).toBe('FauxAmi_2'); + expect(res.body.leaderboard[0].maxPoids).toBe(105); + }); + + it('🔁 Rejouer l\'outil nettoie TOUT (workouts et records inclus)', async () => { + await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + await request(app) + .post('/api/debug/godmode/mock-social') + .set('Authorization', `Bearer ${alice.token}`); + + const mocks = await User.find({ pseudo: /^FauxAmi_/ }); + expect(mocks).toHaveLength(4); + + const mockIds = mocks.map((m) => m._id); + const workouts = await Workout.find({ user: { $in: mockIds } }); + expect(workouts).toHaveLength(6 + 9 + 2 + 11); // une seule génération, pas d'accumulation + const records = await ExerciseRecord.find({ user: { $in: mockIds } }); + expect(records).toHaveLength(3 + 3 + 1 + 3); + }); + }); +}); diff --git a/back/validators/auth.validator.js b/back/validators/auth.validator.js index 057e278..dcca35f 100644 --- a/back/validators/auth.validator.js +++ b/back/validators/auth.validator.js @@ -33,6 +33,10 @@ const schemas = { }), email: emailRule, password: registerPwdRule, + // Parrainage optionnel à l'inscription — champ vide toléré (front envoie '') + referralCode: Joi.string().trim().max(20).allow("", null).messages({ + "string.max": "Le code de parrainage est trop long.", + }), }), login: Joi.object({ diff --git a/back/validators/user.validator.js b/back/validators/user.validator.js index 6a67a27..d253ddd 100644 --- a/back/validators/user.validator.js +++ b/back/validators/user.validator.js @@ -22,6 +22,10 @@ const userSchemas = { shapeId: Joi.string().max(40).required(), colorId: Joi.string().max(40).required(), }), + + updateShowcase: Joi.object({ + achievementIds: Joi.array().items(Joi.string().max(60)).max(3).required(), + }), }; module.exports = userSchemas; \ No newline at end of file diff --git a/front/src/components/profile/AchievementShowcase.js b/front/src/components/profile/AchievementShowcase.js index 1f183f1..44127cf 100644 --- a/front/src/components/profile/AchievementShowcase.js +++ b/front/src/components/profile/AchievementShowcase.js @@ -1,17 +1,21 @@ -import React, { useRef, useEffect } from 'react'; -import { View, Text, StyleSheet, Animated } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, Modal, ScrollView, TouchableOpacity, Pressable, Dimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { RARITY_META } from '../../services/inventory.service'; +import { FeaturedModal } from './TrophySlot'; // ─── AchievementShowcase ────────────────────────────────────────────────────── -// Vitrine des trophées backend V2 (reward.controller.js ACHIEVEMENT_CATALOG) : -// anniversaire, collection d'objets, social. Distinct du système de trophées -// V1 100% local (trophyCatalog.js / TrophyGrid) qui n'est pas consultable -// pour un tiers faute d'accès à ses logs de séances. +// Résumé compact (X/50 débloqués + barre de progression) avec un bouton +// "Voir le détail" qui ouvre le catalogue complet dans une modale, groupé par +// catégorie — évite de dérouler 50 badges directement sur le profil. +// +// Couvre les deux catalogues fusionnés côté back (buildAchievementsView) : +// le V2 backend ({name, description}, ~9 trophées) et le miroir du V1 local +// ({label, condition, epicDesc, gradientColors}, ~41 trophées). // // Props : -// achievements Array<{ id, name, description, category, hidden, unlocked, unlockedAt }> +// achievements Array<{ id, name|label, description|condition, category, hidden, unlocked, unlockedAt }> // stats { total, unlocked, percentage } const ICON_BY_ID = { @@ -35,58 +39,144 @@ const RARITY_BY_ID = { }; const CATEGORY_COLOR = { - profile: Colors.rankViolet, - collection: Colors.primary, - social: '#22D3EE', + profile: Colors.rankViolet, + collection: Colors.primary, + social: '#22D3EE', + heritage: '#FE7439', + force: '#DC2626', + exploration: '#22C55E', + secret: '#6366F1', + corps: '#D1D5DB', + regularite: '#10B981', + special: '#FE7439', + ultime: '#FFD700', +}; + +const CATEGORY_LABELS = { + profile: 'Profil', + collection: 'Collection', + social: 'Social', + heritage: 'Héritage', + force: 'Force', + exploration: 'Exploration', + secret: 'Secrets', + corps: 'Poids du corps', + regularite: 'Régularité', + special: 'Spécial', + ultime: 'Ultime', }; +const CATEGORY_ORDER = [ + 'heritage', 'force', 'exploration', 'corps', 'regularite', + 'collection', 'profile', 'social', 'special', 'secret', 'ultime', +]; + +// Hauteur numérique fixe (et non un pourcentage) : sur react-native-web, un +// enfant `flex: 1` dans un parent contraint seulement par `maxHeight` (sans +// `height`) calcule mal sa zone scrollable — le scroll ne répondait qu'au +// centre de la modale, jamais en haut ni en bas. +const SHEET_MAX_HEIGHT = Math.round(Dimensions.get('window').height * 0.82); + function colorFor(achievement) { + if (achievement.color) return achievement.color; const rarity = RARITY_BY_ID[achievement.id]; if (rarity) return RARITY_META[rarity].color; return CATEGORY_COLOR[achievement.category] || Colors.primary; } +// Normalise les deux formes de catalogue (backend V2 vs miroir local) vers un +// seul shape exploitable par TrophySlot/FeaturedModal (mêmes composants que +// la Vitrine du profil). +export function normalizeAchievement(a) { + const color = colorFor(a); + const hiddenLocked = a.hidden && !a.unlocked; + return { + ...a, + label: a.label || a.name || '', + condition: hiddenLocked ? '???' : (a.condition || a.description || ''), + epicDesc: hiddenLocked ? 'Ce trophée est encore secret.' : (a.epicDesc || a.description || a.condition || ''), + color, + gradientColors: a.gradientColors || [color, color, color], + icon: hiddenLocked ? 'help-circle' : (a.icon || ICON_BY_ID[a.id] || 'trophy'), + tier: a.tier || 'bronze', + }; +} + export default function AchievementShowcase({ achievements = [], stats }) { + const [detailOpen, setDetailOpen] = useState(false); + const [selected, setSelected] = useState(null); + + const normalized = achievements.map(normalizeAchievement); + const byCategory = CATEGORY_ORDER + .map((cat) => ({ cat, items: normalized.filter((a) => a.category === cat) })) + .filter((g) => g.items.length > 0); + return ( {stats && ( - - {stats.unlocked}/{stats.total} trophées débloqués - - - + + + {stats.unlocked}/{stats.total} trophées débloqués + + + + {stats.percentage}% )} - - {achievements.map((a, i) => ( - - ))} - + setDetailOpen(true)} activeOpacity={0.8}> + + Voir le détail + + + + {detailOpen && ( + setDetailOpen(false)}> + setDetailOpen(false)}> + {}}> + + Trophées {stats ? `· ${stats.unlocked}/${stats.total}` : ''} + setDetailOpen(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + + + + + {byCategory.map(({ cat, items }) => ( + + {CATEGORY_LABELS[cat] || cat} + + {items.map((a) => ( + setSelected(a)} /> + ))} + + + ))} + + + + + + )} + + {selected && ( + setSelected(null)} /> + )} ); } -function AchievementBadge({ achievement, color, index }) { - const scale = useRef(new Animated.Value(0.85)).current; - const opacity = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.parallel([ - Animated.spring(scale, { toValue: 1, friction: 7, tension: 60, delay: index * 40, useNativeDriver: true }), - Animated.timing(opacity, { toValue: 1, duration: 260, delay: index * 40, useNativeDriver: true }), - ]).start(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const { unlocked, hidden } = achievement; - const icon = hidden && !unlocked ? 'help-circle' : (ICON_BY_ID[achievement.id] || 'trophy'); - +function AchievementBadge({ achievement, onPress }) { + const { unlocked, color, icon, label } = achievement; return ( - + - {achievement.name} + {label} {!unlocked && ( )} - + ); } const styles = StyleSheet.create({ statsBar: { flexDirection: 'row', alignItems: 'center', gap: 10, - marginBottom: 16, + marginBottom: 12, }, - statsTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600' }, + statsTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600', marginBottom: 6 }, statsBarTrack: { - flex: 1, height: 6, borderRadius: 3, + height: 6, borderRadius: 3, backgroundColor: 'rgba(255,255,255,0.08)', overflow: 'hidden', }, statsBarFill: { height: '100%', backgroundColor: Colors.gold, borderRadius: 3 }, - statsPct: { color: Colors.gold, fontSize: 12, fontWeight: '800' }, + statsPct: { color: Colors.gold, fontSize: 14, fontWeight: '800' }, + + detailBtn: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, + borderWidth: 1, borderColor: 'rgba(255,215,0,0.3)', backgroundColor: 'rgba(255,215,0,0.06)', + borderRadius: 12, paddingVertical: 11, + }, + detailBtnText: { color: Colors.gold, fontSize: 13, fontWeight: '700' }, + + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.74)', justifyContent: 'flex-end' }, + sheet: { + height: SHEET_MAX_HEIGHT, backgroundColor: Colors.bgAbyss, + borderTopLeftRadius: 26, borderTopRightRadius: 26, + borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', + paddingHorizontal: 18, paddingTop: 18, paddingBottom: 10, + }, + sheetHeader: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + marginBottom: 16, + }, + sheetTitle: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' }, + scrollArea: { flex: 1 }, + categoryLabel: { + color: Colors.textMuted, fontSize: 11, fontWeight: '800', + letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10, + }, grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, diff --git a/front/src/components/profile/FriendShowcaseGrid.js b/front/src/components/profile/FriendShowcaseGrid.js new file mode 100644 index 0000000..724ab41 --- /dev/null +++ b/front/src/components/profile/FriendShowcaseGrid.js @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import { TrophySlot, FeaturedModal } from './TrophySlot'; +import { normalizeAchievement } from './AchievementShowcase'; + +// Vitrine d'un ami : mêmes TrophySlot (gradient + glow + gleam) que la Vitrine +// du profil — aucune réimplémentation visuelle distincte. + +export default function FriendShowcaseGrid({ pseudo, entries = [] }) { + const [selected, setSelected] = useState(null); + if (entries.length === 0) return null; + + const trophies = entries.map((a) => ({ ...normalizeAchievement(a), unlocked: true })); + + return ( + + + + VITRINE DE {(pseudo || '').toUpperCase()} + + + {trophies.map((trophy) => ( + setSelected(trophy)} /> + ))} + + + {selected && ( + setSelected(null)} /> + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: 'rgba(255,215,0,0.05)', + borderWidth: 1, borderColor: 'rgba(255,215,0,0.22)', + borderRadius: 16, padding: 14, marginTop: 14, + }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 12, + }, + title: { + color: Colors.gold, fontSize: 10.5, fontWeight: '800', letterSpacing: 1.2, + }, + row: { flexDirection: 'row', gap: 10 }, +}); diff --git a/front/src/components/profile/TrophyGrid.js b/front/src/components/profile/TrophyGrid.js index d9a788b..6918ae3 100644 --- a/front/src/components/profile/TrophyGrid.js +++ b/front/src/components/profile/TrophyGrid.js @@ -1,144 +1,7 @@ -import React, { useRef, useEffect, useState } from 'react'; -import { - View, Text, StyleSheet, Animated, - Modal, TouchableOpacity, Pressable, -} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Colors } from '../../constants/theme'; +import React, { useState } from 'react'; +import { View, StyleSheet } from 'react-native'; import { MAX_FEATURED } from '../../hooks/useFeaturedTrophies'; - -const GLOW = { - bronze: { opacity: 0.55, radius: 12, elevation: 8 }, - silver: { opacity: 0.78, radius: 20, elevation: 16 }, - gold: { opacity: 0.92, radius: 28, elevation: 22 }, - platinum: { opacity: 1.0, radius: 36, elevation: 28 }, - diamond: { opacity: 1.0, radius: 44, elevation: 36 }, - // legacy compat - low: { opacity: 0.55, radius: 12, elevation: 8 }, - medium: { opacity: 0.82, radius: 22, elevation: 18 }, - high: { opacity: 1.0, radius: 36, elevation: 28 }, -}; - -// ─── TrophySlot ─────────────────────────────────────────────────────────────── - -function TrophySlot({ trophy, onPress }) { - const { icon, label, condition, desc, color, gradientColors, unlocked, tier, glowIntensity } = trophy; - const gleamX = useRef(new Animated.Value(-56)).current; - - useEffect(() => { - if (!unlocked) return; - const loop = Animated.loop( - Animated.sequence([ - Animated.timing(gleamX, { toValue: -56, duration: 1, useNativeDriver: true }), - Animated.delay(2600), - Animated.timing(gleamX, { toValue: 76, duration: 520, useNativeDriver: true }), - Animated.delay(380), - ]), - ); - loop.start(); - return () => loop.stop(); - }, [unlocked, gleamX]); - - const glow = GLOW[tier] || GLOW[glowIntensity] || GLOW.bronze; - - return ( - - - {unlocked ? ( - - - - - ) : ( - - - - )} - - {label} - - {condition || desc || ''} - - - ); -} - -// ─── EmptySlot ──────────────────────────────────────────────────────────────── - -function EmptySlot({ onPress }) { - return ( - - - - - Choisir - Voir tout - - ); -} - -// ─── FeaturedModal : tap sur un trophée mis en avant ───────────────────────── - -function FeaturedModal({ trophy, onClose, onUnfeature }) { - const { icon, label, condition, desc, epicDesc, color, gradientColors, unlocked } = trophy; - const scale = useRef(new Animated.Value(0.88)).current; - const opacity = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.parallel([ - Animated.spring(scale, { toValue: 1, useNativeDriver: true, tension: 130, friction: 8 }), - Animated.timing(opacity, { toValue: 1, duration: 190, useNativeDriver: true }), - ]).start(); - }, [scale, opacity]); - - return ( - - - - {}}> - - - - - - - {label} - {condition || desc || ''} - - - - Débloqué · En vitrine - - - - - {epicDesc || ''} - - - - Retirer de la vitrine - - - - Fermer - - - - - - ); -} - -// ─── TrophyGrid ─────────────────────────────────────────────────────────────── +import { TrophySlot, EmptySlot, FeaturedModal } from './TrophySlot'; // featuredIds + toggleFeatured proviennent du parent (ProfileScreen via useFeaturedTrophies) // → évite un état stale quand l'utilisateur revient de TrophyRoomScreen @@ -178,55 +41,6 @@ export default function TrophyGrid({ evaluatedCatalog = [], featuredIds = [], to ); } -// ─── Styles ─────────────────────────────────────────────────────────────────── - const styles = StyleSheet.create({ row: { flexDirection: 'row', gap: 10 }, - - slot: { - flex: 1, backgroundColor: 'rgba(22,22,31,0.97)', borderRadius: 16, - paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', - borderWidth: 0.5, borderColor: 'rgba(255,255,255,0.06)', - }, - iconWrap: { width: 56, height: 56, borderRadius: 18, marginBottom: 10 }, - iconGradient: { - width: 56, height: 56, borderRadius: 18, alignItems: 'center', justifyContent: 'center', - overflow: 'hidden', borderTopWidth: 0.8, borderTopColor: 'rgba(255,255,255,0.48)', - borderWidth: 0.8, borderColor: 'rgba(255,255,255,0.08)', - }, - gleam: { position: 'absolute', top: -8, width: 16, height: 80, backgroundColor: 'rgba(255,255,255,0.30)', borderRadius: 6 }, - iconLocked: { width: 56, height: 56, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.05)', alignItems: 'center', justifyContent: 'center', borderWidth: 0.8, borderColor: 'rgba(255,255,255,0.07)' }, - label: { fontSize: 12, fontWeight: '800', textAlign: 'center', letterSpacing: 0.3 }, - desc: { fontSize: 10, fontWeight: '600', textAlign: 'center', marginTop: 3, letterSpacing: 0.2 }, - - emptySlot: { - flex: 1, borderRadius: 16, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', - borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)', borderStyle: 'dashed', - backgroundColor: 'rgba(255,255,255,0.02)', - }, - emptyIcon: { - width: 56, height: 56, borderRadius: 18, marginBottom: 10, - alignItems: 'center', justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.04)', - borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', - }, - emptyLabel: { fontSize: 12, fontWeight: '700', color: 'rgba(255,255,255,0.22)', letterSpacing: 0.3 }, - emptyHint: { fontSize: 10, fontWeight: '500', color: 'rgba(255,255,255,0.14)', marginTop: 3 }, - - overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.74)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }, - modalCard: { width: '100%', backgroundColor: 'rgba(20,20,28,0.94)', borderRadius: 28, borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', borderTopColor: 'rgba(255,255,255,0.22)', shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.70, shadowRadius: 48, elevation: 32 }, - modalInner: { paddingVertical: 32, paddingHorizontal: 24, alignItems: 'center' }, - modalIconShadow: { marginBottom: 20, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.90, shadowRadius: 32, elevation: 24 }, - modalIconGradient: { width: 96, height: 96, borderRadius: 30, alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.55)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.10)' }, - modalLabel: { fontSize: 26, fontWeight: '900', letterSpacing: 0.4, marginBottom: 5 }, - modalCondition:{ color: Colors.textMuted, fontSize: 13, fontWeight: '600', letterSpacing: 0.4, marginBottom: 14 }, - badge: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 14, paddingVertical: 7, borderRadius: 20, borderWidth: 1, marginBottom: 4 }, - badgeText: { fontSize: 12, fontWeight: '700', letterSpacing: 0.5 }, - modalDivider: { width: '55%', height: 1, marginVertical: 20 }, - modalEpicDesc: { color: Colors.textSecondary, fontSize: 14, fontWeight: '500', textAlign: 'center', lineHeight: 22, marginBottom: 20, paddingHorizontal: 4 }, - - unfeaturedBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 12, paddingHorizontal: 28, borderRadius: 14, borderWidth: 1, marginBottom: 10 }, - unfeaturedBtnText: { fontSize: 14, fontWeight: '700' }, - closeBtn: { paddingVertical: 10, paddingHorizontal: 40, borderRadius: 14, borderWidth: 1 }, - closeTxt: { fontSize: 14, fontWeight: '700', color: Colors.textMuted, letterSpacing: 0.4 }, }); diff --git a/front/src/components/profile/TrophySlot.js b/front/src/components/profile/TrophySlot.js new file mode 100644 index 0000000..e3ed428 --- /dev/null +++ b/front/src/components/profile/TrophySlot.js @@ -0,0 +1,199 @@ +import React, { useRef, useEffect } from 'react'; +import { + View, Text, StyleSheet, Animated, + Modal, TouchableOpacity, Pressable, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Colors } from '../../constants/theme'; + +// Rendu partagé entre la Vitrine du profil (ProfileScreen/TrophyGrid) et la +// vitrine d'un ami (FriendProfileScreen) — un seul composant, un seul style. + +export const GLOW = { + bronze: { opacity: 0.55, radius: 12, elevation: 8 }, + silver: { opacity: 0.78, radius: 20, elevation: 16 }, + gold: { opacity: 0.92, radius: 28, elevation: 22 }, + platinum: { opacity: 1.0, radius: 36, elevation: 28 }, + diamond: { opacity: 1.0, radius: 44, elevation: 36 }, + // legacy compat + low: { opacity: 0.55, radius: 12, elevation: 8 }, + medium: { opacity: 0.82, radius: 22, elevation: 18 }, + high: { opacity: 1.0, radius: 36, elevation: 28 }, +}; + +// ─── TrophySlot ─────────────────────────────────────────────────────────────── + +export function TrophySlot({ trophy, onPress }) { + const { icon, label, condition, desc, color, gradientColors, unlocked, tier, glowIntensity } = trophy; + const gleamX = useRef(new Animated.Value(-56)).current; + + useEffect(() => { + if (!unlocked) return; + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(gleamX, { toValue: -56, duration: 1, useNativeDriver: true }), + Animated.delay(2600), + Animated.timing(gleamX, { toValue: 76, duration: 520, useNativeDriver: true }), + Animated.delay(380), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [unlocked, gleamX]); + + const glow = GLOW[tier] || GLOW[glowIntensity] || GLOW.bronze; + + return ( + + + {unlocked ? ( + + + + + ) : ( + + + + )} + + {label} + + {condition || desc || ''} + + + ); +} + +// ─── EmptySlot ──────────────────────────────────────────────────────────────── + +export function EmptySlot({ onPress, label = 'Choisir', hint = 'Voir tout' }) { + return ( + + + + + {label} + {hint} + + ); +} + +// ─── FeaturedModal : tap sur un trophée mis en avant ───────────────────────── +// onUnfeature optionnel : absent → lecture seule (vitrine d'un ami) + +export function FeaturedModal({ trophy, onClose, onUnfeature }) { + const { icon, label, condition, desc, epicDesc, color, gradientColors, unlocked } = trophy; + const scale = useRef(new Animated.Value(0.88)).current; + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.spring(scale, { toValue: 1, useNativeDriver: true, tension: 130, friction: 8 }), + Animated.timing(opacity, { toValue: 1, duration: 190, useNativeDriver: true }), + ]).start(); + }, [scale, opacity]); + + return ( + + + + {}}> + + + + + + + {label} + {condition || desc || ''} + + {unlocked && ( + + + {onUnfeature ? 'Débloqué · En vitrine' : 'Débloqué'} + + )} + + + + {epicDesc || ''} + + {onUnfeature && ( + + + Retirer de la vitrine + + )} + + + Fermer + + + + + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +export const styles = StyleSheet.create({ + row: { flexDirection: 'row', gap: 10 }, + + slot: { + flex: 1, backgroundColor: 'rgba(22,22,31,0.97)', borderRadius: 16, + paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', + borderWidth: 0.5, borderColor: 'rgba(255,255,255,0.06)', + }, + iconWrap: { width: 56, height: 56, borderRadius: 18, marginBottom: 10 }, + iconGradient: { + width: 56, height: 56, borderRadius: 18, alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', borderTopWidth: 0.8, borderTopColor: 'rgba(255,255,255,0.48)', + borderWidth: 0.8, borderColor: 'rgba(255,255,255,0.08)', + }, + gleam: { position: 'absolute', top: -8, width: 16, height: 80, backgroundColor: 'rgba(255,255,255,0.30)', borderRadius: 6 }, + iconLocked: { width: 56, height: 56, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.05)', alignItems: 'center', justifyContent: 'center', borderWidth: 0.8, borderColor: 'rgba(255,255,255,0.07)' }, + label: { fontSize: 12, fontWeight: '800', textAlign: 'center', letterSpacing: 0.3 }, + desc: { fontSize: 10, fontWeight: '600', textAlign: 'center', marginTop: 3, letterSpacing: 0.2 }, + + emptySlot: { + flex: 1, borderRadius: 16, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)', borderStyle: 'dashed', + backgroundColor: 'rgba(255,255,255,0.02)', + }, + emptyIcon: { + width: 56, height: 56, borderRadius: 18, marginBottom: 10, + alignItems: 'center', justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', + }, + emptyLabel: { fontSize: 12, fontWeight: '700', color: 'rgba(255,255,255,0.22)', letterSpacing: 0.3 }, + emptyHint: { fontSize: 10, fontWeight: '500', color: 'rgba(255,255,255,0.14)', marginTop: 3 }, + + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.74)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }, + modalCard: { width: '100%', backgroundColor: 'rgba(20,20,28,0.94)', borderRadius: 28, borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', borderTopColor: 'rgba(255,255,255,0.22)', shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.70, shadowRadius: 48, elevation: 32 }, + modalInner: { paddingVertical: 32, paddingHorizontal: 24, alignItems: 'center' }, + modalIconShadow: { marginBottom: 20, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.90, shadowRadius: 32, elevation: 24 }, + modalIconGradient: { width: 96, height: 96, borderRadius: 30, alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.55)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.10)' }, + modalLabel: { fontSize: 26, fontWeight: '900', letterSpacing: 0.4, marginBottom: 5 }, + modalCondition:{ color: Colors.textMuted, fontSize: 13, fontWeight: '600', letterSpacing: 0.4, marginBottom: 14 }, + badge: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 14, paddingVertical: 7, borderRadius: 20, borderWidth: 1, marginBottom: 4 }, + badgeText: { fontSize: 12, fontWeight: '700', letterSpacing: 0.5 }, + modalDivider: { width: '55%', height: 1, marginVertical: 20 }, + modalEpicDesc: { color: Colors.textSecondary, fontSize: 14, fontWeight: '500', textAlign: 'center', lineHeight: 22, marginBottom: 20, paddingHorizontal: 4 }, + + unfeaturedBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 12, paddingHorizontal: 28, borderRadius: 14, borderWidth: 1, marginBottom: 10 }, + unfeaturedBtnText: { fontSize: 14, fontWeight: '700' }, + closeBtn: { paddingVertical: 10, paddingHorizontal: 40, borderRadius: 14, borderWidth: 1 }, + closeTxt: { fontSize: 14, fontWeight: '700', color: Colors.textMuted, letterSpacing: 0.4 }, +}); diff --git a/front/src/hooks/useFeaturedTrophies.js b/front/src/hooks/useFeaturedTrophies.js index 03c9d90..0576a2d 100644 --- a/front/src/hooks/useFeaturedTrophies.js +++ b/front/src/hooks/useFeaturedTrophies.js @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { updateShowcase } from '../services/profile.service'; const FEATURED_KEY = 'athly:pref:featuredTrophies:v1'; export const MAX_FEATURED = 3; @@ -28,6 +29,9 @@ export function useFeaturedTrophies() { next = [...prev.slice(1), trophyId]; } AsyncStorage.setItem(FEATURED_KEY, JSON.stringify(next)).catch(() => {}); + // Synchro backend fire-and-forget : la vitrine devient visible sur le + // profil public — un échec réseau ne casse jamais l'affichage local. + updateShowcase(next).catch(() => {}); return next; }); }, []); diff --git a/front/src/screens/Auth/RegisterScreen.js b/front/src/screens/Auth/RegisterScreen.js index 89921e9..bae3cfa 100644 --- a/front/src/screens/Auth/RegisterScreen.js +++ b/front/src/screens/Auth/RegisterScreen.js @@ -205,6 +205,7 @@ export default function RegisterScreen({ navigation }) { const [pseudo, setPseudo] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [referralCode, setReferralCode] = useState(''); const [confirm, setConfirm] = useState(''); const [loading, setLoading] = useState(false); @@ -259,7 +260,7 @@ export default function RegisterScreen({ navigation }) { try { setLoading(true); setGlobalErr(''); - await register({ pseudo, email, password }); + await register({ pseudo, email, password, referralCode: referralCode.trim() }); navigation.navigate('EmailVerification', { email }); } catch (error) { const status = error?.status; @@ -270,6 +271,9 @@ export default function RegisterScreen({ navigation }) { } else if (status >= 500) { setErrType('info'); setGlobalErr('Une erreur est survenue, notre équipe est sur le coup.'); + } else if (msg.toLowerCase().includes('parrainage')) { + setErrType('error'); + setGlobalErr('Code de parrainage invalide. Vérifie-le ou laisse le champ vide.'); } else if (msg.toLowerCase().includes('email')) { setErrType('error'); setGlobalErr('Cet email est déjà utilisé.'); @@ -280,7 +284,7 @@ export default function RegisterScreen({ navigation }) { } finally { setLoading(false); } - }, [pseudo, email, password, navigation]); + }, [pseudo, email, password, referralCode, navigation]); // ── Soumission : validation + vérification de force ─────────────────────── const handleSubmit = useCallback(() => { @@ -380,6 +384,16 @@ export default function RegisterScreen({ navigation }) { error={confirmErr} /> + setReferralCode(v.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + {globalErr ? : null} { + if (logsLoading) return; + const unlockedIds = evaluatedTrophies.filter((t) => t.naturalUnlocked).map((t) => t.id).sort(); + const key = unlockedIds.join(','); + if (unlockedIds.length === 0 || key === lastSyncedRef.current) return; + lastSyncedRef.current = key; + syncLocalAchievements(unlockedIds).catch(() => { lastSyncedRef.current = ''; }); + }, [evaluatedTrophies, logsLoading]); + // Active profile theme (null when 'auto' or not set) const activeTheme = useMemo(() => { const t = getTheme(profileThemeId); diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 11c80b4..29c8b52 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -1,7 +1,7 @@ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, - StatusBar, Switch, Alert, TextInput, ActivityIndicator, Modal, + StatusBar, Switch, Alert, TextInput, ActivityIndicator, Modal, Share, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -50,7 +50,7 @@ const DEV_TAP_TARGET = 10; export default function SettingsScreen({ navigation }) { const { signOut } = useAuth(); - const { setUser, refetch: refetchUser } = useUser(); + const { user, setUser, refetch: refetchUser } = useUser(); const { showToast } = useToast(); const { totalXP, sessionLogs, activityLogs, refresh, clearAll: clearWorkoutLogs } = useWorkoutLogs(); const { clearAll: clearSavedWorkouts } = useSavedWorkouts(); @@ -193,6 +193,21 @@ export default function SettingsScreen({ navigation }) { } }, [tapCount, devVisible]); + // Récupère le profil backend au montage : garantit que referralCode est + // présent (généré paresseusement par getMe pour les comptes existants). + useEffect(() => { refetchUser(); }, [refetchUser]); + + const handleShareReferral = useCallback(async () => { + if (!user?.referralCode) return; + try { + await Share.share({ + message: `Rejoins-moi sur Athly ! Utilise mon code de parrainage ${user.referralCode} à l'inscription : on gagne chacun un Gel de Streak et un Coupon de Niveau.`, + }); + } catch (_) { + // Partage annulé ou indisponible : rien à faire + } + }, [user?.referralCode]); + const handleNotifToggle = useCallback(async (val) => { if (val) { const granted = await requestNotificationPermissions(); @@ -410,6 +425,29 @@ export default function SettingsScreen({ navigation }) { + {/* ═══ PARRAINAGE ═══════════════════════════════════════════════════════ */} + + + + + {user?.referralCode || '…'} + + + + + + + + Partage ton code : ton filleul et toi recevez chacun un Gel de Streak et un Coupon + de Niveau, et vous devenez amis automatiquement. + + {/* ═══ UNITÉS ═══════════════════════════════════════════════════════════ */} @@ -972,6 +1010,22 @@ const styles = StyleSheet.create({ valueText: { color: Colors.textMuted, fontSize: 14, fontWeight: '500' }, segRow: { flexDirection: 'row', gap: 6 }, + + // ── Parrainage ── + referralRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + referralCode: { + color: Colors.gold, fontSize: 14, fontWeight: '800', letterSpacing: 1.2, + }, + referralShareBtn: { + width: 32, height: 32, borderRadius: 10, + backgroundColor: 'rgba(254,116,57,0.12)', + borderWidth: 1, borderColor: 'rgba(254,116,57,0.30)', + justifyContent: 'center', alignItems: 'center', + }, + referralHint: { + color: Colors.textMuted, fontSize: 11, lineHeight: 16, + marginTop: 8, marginHorizontal: 4, + }, seg: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8, borderWidth: 1, borderColor: GRP_BDR, backgroundColor: 'rgba(255,255,255,0.04)' }, segActive: { backgroundColor: Colors.primary, borderColor: Colors.primary }, segText: { color: Colors.textSecondary, fontSize: 12, fontWeight: '700' }, diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index e71eff8..249ee8b 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -15,6 +15,7 @@ import HeroLevelCard from '../../components/profile/HeroLevelCard'; import StreakBadge from '../../components/profile/StreakBadge'; import EmberParticles from '../../components/profile/EmberParticles'; import AchievementShowcase from '../../components/profile/AchievementShowcase'; +import FriendShowcaseGrid from '../../components/profile/FriendShowcaseGrid'; // ─── FriendProfileScreen ────────────────────────────────────────────────────── // Profil public d'un ami (Brique III) : miroir en lecture seule de notre propre @@ -107,6 +108,11 @@ export default function FriendProfileScreen({ route, navigation }) { const equippedFrame = profile.user.equippedFrame || { shapeId: 'circle', colorId: 'none' }; + // Vitrine : IDs mis en avant résolus en entrées complètes du catalogue unifié + const showcasedEntries = (profile.showcasedAchievements || []) + .map((id) => (profile.achievements || []).find((a) => a.id === id)) + .filter(Boolean); + return ( @@ -152,6 +158,9 @@ export default function FriendProfileScreen({ route, navigation }) { )} + {/* ── Vitrine de l'ami : ses trophées mis en avant (mêmes TrophySlot que le profil) ── */} + + {/* ── Statut d'amitié ── */} diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index d33b2d0..2533ec2 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -12,9 +12,10 @@ import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import ConfirmModal from '../../components/common/ConfirmModal'; import { searchUsers, sendFriendRequest, acceptFriendRequest, declineFriendRequest, - getFriendsList, getPendingRequests, getLeaderboard, + getFriendsList, getPendingRequests, getLeaderboard, getExerciseLeaderboard, getMyGroup, inviteToGroup, respondToGroupInvite, shakeMember, checkGroupStreak, leaveGroup, } from '../../services/social.service'; +import { MAJOR_EXERCISES } from '../../data/majorExercises'; const SEGMENTS = [ { key: 'friends', label: 'Amis', icon: 'people' }, @@ -309,6 +310,36 @@ function FriendsSegment({ const PODIUM_COLORS = ['#FFD700', '#C0C0C0', '#CD7F32']; function LeaderboardSegment({ leaderboard }) { + const [mode, setMode] = useState('xp'); // 'xp' | 'records' + + return ( + <> + {/* ── Bascule XP / Records ── */} + + setMode('xp')} + activeOpacity={0.8} + > + + XP + + setMode('records')} + activeOpacity={0.8} + > + + Records + + + + {mode === 'xp' ? : } + + ); +} + +function XpLeaderboard({ leaderboard }) { const podium = leaderboard.slice(0, 3); const rest = leaderboard.slice(3); @@ -316,7 +347,7 @@ function LeaderboardSegment({ leaderboard }) { <> {leaderboard.length < 2 ? ( - 🏆 + Ajoute des amis pour lancer la compétition ! ) : ( @@ -356,6 +387,69 @@ function LeaderboardSegment({ leaderboard }) { ); } +// ── Classement par exercice (records du réseau d'amis) ───────────────────────── + +function RecordsLeaderboard() { + const [exercise, setExercise] = useState(MAJOR_EXERCISES[0].name); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + getExerciseLeaderboard(exercise) + .then((res) => { if (!cancelled) setRows(res.leaderboard ?? []); }) + .catch(() => { if (!cancelled) setRows([]); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [exercise]); + + return ( + <> + {/* ── Choix de l'exercice ── */} + + {MAJOR_EXERCISES.map((exo) => { + const active = exo.name === exercise; + return ( + setExercise(exo.name)} + activeOpacity={0.8} + > + {exo.name} + + ); + })} + + + {loading ? ( + + ) : rows.length === 0 ? ( + + + + Aucun record sur cet exercice dans ton réseau.{'\n'}Sois le premier à poser la barre ! + + + ) : rows.map((entry, i) => ( + + + + {entry.position <= 3 ? ['🥇', '🥈', '🥉'][entry.position - 1] : `#${entry.position}`} + + + {entry.user.pseudo}{entry.isMe ? ' (moi)' : ''} + + {entry.maxPoids} kg + × {entry.maxReps} + + + ))} + + ); +} + function PodiumColumn({ entry, height, color, delay }) { const grow = useRef(new Animated.Value(0)).current; @@ -747,6 +841,32 @@ const styles = StyleSheet.create({ boardPos: { color: Colors.textMuted, fontSize: 13, fontWeight: '800', width: 36 }, boardPseudo: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' }, boardXp: { color: Colors.textSecondary, fontSize: 12.5, fontWeight: '700' }, + boardKg: { color: Colors.primary, fontSize: 13.5, fontWeight: '800' }, + boardReps: { color: Colors.textMuted, fontSize: 11.5, fontWeight: '600', marginLeft: 4, width: 34 }, + + // ── Bascule XP / Records ── + modeRow: { flexDirection: 'row', gap: 8, marginTop: 4, marginBottom: 4 }, + modeBtn: { + flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + gap: 5, height: 34, borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', + }, + modeBtnActive: { backgroundColor: Colors.secondaryAccent, borderColor: Colors.secondaryAccent }, + modeTxt: { color: Colors.textMuted, fontSize: 12, fontWeight: '700' }, + modeTxtActive: { color: '#fff' }, + + // ── Chips d'exercices ── + exoChipRow: { gap: 6, paddingVertical: 10, paddingRight: 4 }, + exoChip: { + paddingHorizontal: 12, paddingVertical: 7, borderRadius: 9, + borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', + backgroundColor: 'rgba(255,255,255,0.04)', + }, + exoChipActive: { backgroundColor: Colors.primary, borderColor: Colors.primary }, + exoChipTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600' }, + exoChipTxtActive: { color: '#fff' }, + recordsLoading: { paddingVertical: 30, alignItems: 'center' }, // ── Groupe ── inviteCard: { diff --git a/front/src/services/profile.service.js b/front/src/services/profile.service.js index 3010618..322cbd5 100644 --- a/front/src/services/profile.service.js +++ b/front/src/services/profile.service.js @@ -7,3 +7,10 @@ export async function updateEquippedFrame(shapeId, colorId) { const res = await API.put('/users/me/frame', { shapeId, colorId }); return res.data; } + +// Synchronise la vitrine de trophées (max 3 IDs) vers le backend, pour +// qu'elle soit visible en haut du profil public par les amis. +export async function updateShowcase(achievementIds) { + const res = await API.put('/users/me/showcase', { achievementIds }); + return res.data; +} diff --git a/front/src/services/reward.service.js b/front/src/services/reward.service.js index cc4b60e..ff9d780 100644 --- a/front/src/services/reward.service.js +++ b/front/src/services/reward.service.js @@ -17,3 +17,11 @@ export async function getAchievements() { const res = await API.get('/rewards/achievements'); return res.data; } + +// Synchronise les trophées LOCAUX débloqués (trophyCatalog.js, évalués sur les +// logs AsyncStorage) vers la BDD, pour qu'ils soient visibles sur le profil +// public. Additif et allowlisté côté serveur. +export async function syncLocalAchievements(ids) { + const res = await API.put('/rewards/achievements/sync', { ids }); + return res.data; +} diff --git a/front/src/services/social.service.js b/front/src/services/social.service.js index b2082a9..eceeb62 100644 --- a/front/src/services/social.service.js +++ b/front/src/services/social.service.js @@ -42,6 +42,12 @@ export async function getLeaderboard() { return res.data; } +// Classement par exercice au sein du réseau d'amis (meilleur poids soulevé). +export async function getExerciseLeaderboard(exercise) { + const res = await API.get(`/exercises/leaderboard?exercise=${encodeURIComponent(exercise)}`); + return res.data; +} + // ─── Groupes de Streak (Brique IV) ──────────────────────────────────────────── export async function getMyGroup() { From abcd318235b19fc76d592bf1a5ffd8deeee46aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 3 Jul 2026 17:52:42 +0200 Subject: [PATCH 14/31] feat: prestige unique rouge sang, cadre croc de dragon, theme 100 coffres, trophees coffres et super god mode all-items --- back/controllers/debug.controller.js | 185 ++++++- back/controllers/friend.controller.js | 22 +- back/controllers/groupStreak.controller.js | 25 + back/controllers/inventory.controller.js | 110 ++++- back/controllers/reward.controller.js | 100 ++++ back/controllers/user.controller.js | 19 + back/data/localTrophyCatalog.js | 166 +++++++ back/jest.config.js | 7 +- back/models/StreakGroup.js | 6 + back/models/User.js | 18 + back/routes/debug.routes.js | 8 +- back/routes/inventory.routes.js | 3 + back/routes/reward.routes.js | 4 + back/routes/user.routes.js | 5 +- back/services/user.service.js | 24 + back/services/workout.service.js | 5 +- back/tests/bloodSangRewards.test.js | 454 ++++++++++++++++++ back/tests/setupMocks.js | 17 + back/tests/socialEngine.test.js | 12 +- back/tests/trophyUnification.test.js | 239 +++++++++ back/validators/user.validator.js | 4 + .../components/profile/AchievementShowcase.js | 238 +++++++-- front/src/components/profile/AvatarFrame.js | 134 ++++++ front/src/components/profile/BorderPicker.js | 29 +- .../components/profile/FriendShowcaseGrid.js | 49 ++ front/src/components/profile/HeroLevelCard.js | 39 +- front/src/components/profile/TrophyGrid.js | 192 +------- front/src/components/profile/TrophySlot.js | 211 ++++++++ .../social/FriendshipLevelUpModal.js | 147 ++++++ front/src/constants/theme.js | 12 + front/src/data/backendTrophyCategories.js | 10 + front/src/data/profileThemes.js | 21 +- front/src/hooks/useFeaturedTrophies.js | 4 + front/src/screens/Profile/InventoryScreen.js | 72 ++- front/src/screens/Profile/ProfileScreen.js | 29 +- front/src/screens/Profile/SettingsScreen.js | 213 +++++++- front/src/screens/Profile/TrophyRoomScreen.js | 107 ++++- .../src/screens/Social/FriendProfileScreen.js | 9 + front/src/screens/Social/SocialScreen.js | 59 ++- front/src/services/debug.service.js | 27 ++ front/src/services/inventory.service.js | 28 +- front/src/services/profile.service.js | 6 + front/src/services/reward.service.js | 8 + 43 files changed, 2748 insertions(+), 329 deletions(-) create mode 100644 back/data/localTrophyCatalog.js create mode 100644 back/tests/bloodSangRewards.test.js create mode 100644 back/tests/setupMocks.js create mode 100644 back/tests/trophyUnification.test.js create mode 100644 front/src/components/profile/FriendShowcaseGrid.js create mode 100644 front/src/components/profile/TrophySlot.js create mode 100644 front/src/components/social/FriendshipLevelUpModal.js create mode 100644 front/src/data/backendTrophyCategories.js diff --git a/back/controllers/debug.controller.js b/back/controllers/debug.controller.js index 537af63..8da09f1 100644 --- a/back/controllers/debug.controller.js +++ b/back/controllers/debug.controller.js @@ -5,7 +5,8 @@ const bcrypt = require('bcrypt'); const User = require('../models/User'); const Friendship = require('../models/Friendship'); const { xpForLevel, getRankForLevel } = require('../utils/levelHelpers'); -const { addItemAtomic } = require('../services/inventory.service'); +const { addItemAtomic, addUniqueItemOnce } = require('../services/inventory.service'); +const { checkAndUnlockAchievements } = require('./reward.controller'); const MOCK_PASSWORD_ROUNDS = 10; // comptes jetables, jamais utilisés pour se connecter @@ -65,7 +66,7 @@ exports.syncLevel = async (req, res, next) => { /** * Outil de test : crédite atomiquement `amount` CHEST_KEY à l'utilisateur - * connecté, pour tester l'ouverture de coffre sans cumuler 5h de séance par + * connecté, pour tester l'ouverture de coffre sans cumuler 2h de séance par * palier. Réutilise addItemAtomic (même garde anti-race que openChest/useItem). * * Body : { amount?: number } — défaut 1, borné à 50 pour rester un outil de @@ -94,6 +95,186 @@ exports.giveChests = async (req, res, next) => { } }; +// ───────────────────────────────────────────────────────────────────────────── +// giveAllItems POST /api/debug/godmode/give-all-items +// ───────────────────────────────────────────────────────────────────────────── + +// Rareté d'affichage de chaque itemType — miroir de front/src/services/ +// inventory.service.js (ITEM_CATALOG). Dupliqué volontairement ici : ce +// contrôleur n'a pas de dépendance vers le code front, et cette table est un +// simple outil de QA (pas une règle de jeu, qui reste dans chest.service.js). +const ITEM_RARITY_MAP = { + ENERGY_DRINK: 'common', + CHEST_KEY: 'common', + STREAK_FREEZE: 'rare', + DOUBLE_XP: 'rare', + SUPER_STREAK_FREEZE: 'epic', + TRIPLE_XP: 'epic', + LEVEL_COUPON: 'legendary', + QUINTUPLE_XP: 'legendary', + PROFILE_FRAME_BLOOD_BOND:'unique', + FRAME_COLOR_BLOOD_SANG: 'unique', + THEME_UNLOCK_BLOOD_SANG: 'unique', +}; + +/** + * Outil de test : injecte 1 exemplaire de CHAQUE itemType existant dans + * l'inventaire de l'utilisateur connecté — permet de tester en un clic tous + * les consommables ET tous les cosmétiques Uniques réclamables (cadre, + * couleur, thème), sans enchaîner des dizaines d'actions de jeu. + * + * La liste des itemTypes vient directement de l'enum Mongoose (User.js) : + * toujours synchronisée avec le schéma, aucun risque de dérive si un nouvel + * item est ajouté un jour sans mettre à jour ce contrôleur. + */ +exports.giveAllItems = async (req, res, next) => { + try { + const itemTypes = User.schema.path('inventory').schema.path('itemType').enumValues; + + for (const itemType of itemTypes) { + const rarity = ITEM_RARITY_MAP[itemType] || 'common'; + await addItemAtomic(req.user.id, itemType, rarity, 1); + } + + const user = await User.findById(req.user.id).select('inventory'); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + return res.status(200).json({ + success: true, + message: `${itemTypes.length} objet(s) ajouté(s) (1 exemplaire de chaque).`, + inventory: user.inventory, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateChestsOpened POST /api/debug/godmode/simulate-chests-opened +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : incrémente directement `totalChestsOpened` sans passer par + * de vraies ouvertures de coffre — les trophées gradués (CHEST_1…CHEST_200) + * et le déblocage du thème "Rouge Sang" à 100 coffres nécessiteraient sinon + * des dizaines/centaines d'actions manuelles pour être testés. + * + * Réplique le même octroi que openChest au palier 100 (voir + * inventory.controller.js) pour rester cohérent avec le vrai parcours. + * + * Body : { amount?: number } — défaut 1, borné à 250 (couvre CHEST_200). + */ +exports.simulateChestsOpened = async (req, res, next) => { + try { + const amount = req.body.amount === undefined ? 1 : parseInt(req.body.amount, 10); + if (!Number.isFinite(amount) || amount < 1 || amount > 250) { + return next(createError('amount doit être un entier entre 1 et 250.', 400)); + } + + const updated = await User.findOneAndUpdate( + { _id: req.user.id }, + { $inc: { totalChestsOpened: amount } }, + { returnDocument: 'after' }, + ).select('totalChestsOpened'); + if (!updated) return next(createError('Utilisateur introuvable.', 404)); + + let themeUnlockGranted = false; + if (updated.totalChestsOpened >= 100) { + const granted = await addUniqueItemOnce(req.user.id, 'THEME_UNLOCK_BLOOD_SANG', 'unique'); + themeUnlockGranted = Boolean(granted); + } + + const newlyUnlocked = await checkAndUnlockAchievements(req.user.id); + + return res.status(200).json({ + success: true, + message: `+${amount} coffre(s) ouvert(s) simulé(s) (total : ${updated.totalChestsOpened}).`, + totalChestsOpened: updated.totalChestsOpened, + themeUnlockGranted, + newlyUnlocked, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateReferral POST /api/debug/godmode/simulate-referral +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : crée un compte factice "filleul" avec referredBy pointant + * vers l'utilisateur connecté, pour débloquer FIRST_REFERRAL sans avoir à + * faire créer un vrai second compte et réclamer un code de parrainage. + */ +exports.simulateReferral = async (req, res, next) => { + try { + const myId = req.user.id; + const passwordHash = await bcrypt.hash(crypto.randomBytes(24).toString('hex'), MOCK_PASSWORD_ROUNDS); + + await User.create({ + pseudo: `Filleul_${Date.now().toString(36)}`, + email: `mock-referral-${myId}-${Date.now()}@athly.dev`, + password: passwordHash, + isVerified: true, + referredBy: myId, + }); + + const newlyUnlocked = await checkAndUnlockAchievements(myId); + + return res.status(201).json({ + success: true, + message: 'Parrainage simulé : un filleul factice a été créé.', + newlyUnlocked, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateBirthday POST /api/debug/godmode/simulate-birthday +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : force la date de naissance à aujourd'hui (année neutre) et + * marque le cadeau d'anniversaire comme déjà réclamé cette année — débloque + * BIRTHDAY_SET et BIRTHDAY_CELEBRATED sans attendre le vrai jour J. + * + * Contourne volontairement la garde anti-triche "une seule saisie" de + * setBirthdate (voir reward.controller.js) : c'est un outil de dev, pas le + * parcours utilisateur normal. + */ +exports.simulateBirthday = async (req, res, next) => { + try { + const now = new Date(); + const birthdate = new Date(Date.UTC(2000, now.getUTCMonth(), now.getUTCDate())); + + const user = await User.findByIdAndUpdate( + req.user.id, + { + $set: { + isBirthdateSet: true, + birthdate, + lastBirthdayRewardedYear: now.getUTCFullYear(), + }, + }, + { new: true }, + ); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + const newlyUnlocked = await checkAndUnlockAchievements(req.user.id); + + return res.status(200).json({ + success: true, + message: 'Anniversaire simulé (aujourd\'hui).', + newlyUnlocked, + }); + } catch (err) { + next(err); + } +}; + // ───────────────────────────────────────────────────────────────────────────── // mockSocial POST /api/debug/godmode/mock-social // ───────────────────────────────────────────────────────────────────────────── diff --git a/back/controllers/friend.controller.js b/back/controllers/friend.controller.js index 7530ac1..5e7e7f8 100644 --- a/back/controllers/friend.controller.js +++ b/back/controllers/friend.controller.js @@ -5,7 +5,14 @@ const Friendship = require('../models/Friendship'); const User = require('../models/User'); const Workout = require('../models/Workout'); const ExerciseRecord = require('../models/ExerciseRecord'); -const { ACHIEVEMENT_CATALOG, CATALOG_SIZE } = require('./reward.controller'); +const { ACHIEVEMENT_CATALOG } = require('./reward.controller'); +const { LOCAL_TROPHY_CATALOG } = require('../data/localTrophyCatalog'); + +// Catalogue combiné : backend (achievements serveur) + miroir du catalogue +// LOCAL (V1, synchronisé via PUT /rewards/achievements/sync). Un profil +// d'ami affiche ainsi les 2 systèmes de trophées d'Athly en une seule vue. +const FULL_ACHIEVEMENT_CATALOG = { ...ACHIEVEMENT_CATALOG, ...LOCAL_TROPHY_CATALOG }; +const FULL_CATALOG_SIZE = Object.keys(FULL_ACHIEVEMENT_CATALOG).length; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -63,7 +70,7 @@ function computeStreakFromDates(dates) { function buildAchievementsView(userAchievements) { const unlockedMap = new Map(userAchievements.map((a) => [a.achievementId, a.unlockedAt])); - const achievements = Object.values(ACHIEVEMENT_CATALOG).map((entry) => { + const achievements = Object.values(FULL_ACHIEVEMENT_CATALOG).map((entry) => { const unlocked = unlockedMap.has(entry.id); const unlockedAt = unlockedMap.get(entry.id) ?? null; @@ -81,9 +88,9 @@ function buildAchievementsView(userAchievements) { return { achievements, stats: { - total: CATALOG_SIZE, + total: FULL_CATALOG_SIZE, unlocked: unlockedCount, - percentage: Math.round((unlockedCount / CATALOG_SIZE) * 100), + percentage: Math.round((unlockedCount / FULL_CATALOG_SIZE) * 100), }, }; } @@ -410,7 +417,7 @@ exports.getFriendProfile = async (req, res, next) => { } const friend = await User.findById(friendId) - .select('pseudo level rank xp achievements streakGels totalWorkoutMinutes equippedFrame createdAt'); + .select('pseudo level rank xp achievements showcasedAchievements streakGels totalWorkoutMinutes equippedFrame createdAt'); if (!friend) return next(createError('Utilisateur introuvable.', 404)); const friendObjectId = new mongoose.Types.ObjectId(friendId); @@ -452,6 +459,11 @@ exports.getFriendProfile = async (req, res, next) => { }, achievements, achievementsStats, + // Restreint aux trophées réellement débloqués — défense en profondeur + // contre un désync (ex: trophée retiré après avoir été mis en vitrine). + showcasedAchievements: (friend.showcasedAchievements || []).filter((id) => + friend.achievements.some((a) => a.achievementId === id), + ), records: records.map((r) => ({ exercice: r._id, maxPoids: r.maxPoids, diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index 450f948..14d217e 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -13,6 +13,10 @@ const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); const MAX_GROUP_SIZE = 5; +// Streak de groupe (jours consécutifs) requise, à taille maximale (5 membres), +// pour débloquer la couleur cosmétique Unique "Rouge Sang Unique". +const BLOOD_SANG_STREAK_THRESHOLD = 30; + // Champs publics exposés pour un membre de groupe const MEMBER_PUBLIC_FIELDS = 'pseudo level rank xp'; @@ -463,6 +467,26 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { const xpBonus = computeGroupXpBonus(memberIds.length, group.currentStreak); await Promise.all(memberIds.map((id) => grantGroupXpBonus(id, xpBonus.bonusXp))); + // Récompense cosmétique Unique "Rouge Sang Unique" : streak de groupe de + // 30 jours validée à taille MAXIMALE (5 membres). Octroi unique (garde + // bloodSangAwarded) — chaque membre reçoit l'item à réclamer depuis son + // inventaire (voir inventory.controller.js → claimUniqueItem). + let bloodSangUnlocked = false; + if ( + !group.bloodSangAwarded && + memberIds.length === MAX_GROUP_SIZE && + group.currentStreak >= BLOOD_SANG_STREAK_THRESHOLD + ) { + await Promise.all(memberIds.map((id) => addUniqueItemOnce(id, 'FRAME_COLOR_BLOOD_SANG', 'unique'))); + group.bloodSangAwarded = true; + await group.save(); + bloodSangUnlocked = true; + // FIRST_UNIQUE_ITEM peut se débloquer ici si c'est le tout premier objet + // Unique du membre — doit être vérifié pendant que l'item est encore en + // inventaire (avant toute réclamation qui le consommerait). + await Promise.all(memberIds.map((id) => checkAndUnlockAchievements(id))); + } + return res.status(200).json({ success: true, allValidated: true, @@ -471,6 +495,7 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { xpGain, xpUpdates, groupBonus: { ...xpBonus, memberCount: memberIds.length }, + bloodSangUnlocked, }); } catch (err) { next(err); diff --git a/back/controllers/inventory.controller.js b/back/controllers/inventory.controller.js index 0076539..5d7b08b 100644 --- a/back/controllers/inventory.controller.js +++ b/back/controllers/inventory.controller.js @@ -2,8 +2,9 @@ const User = require('../models/User'); const { drawChestItem } = require('../services/chest.service'); -const { consumeItemAtomic, addItemAtomic, purgeEmptyEntries } = require('../services/inventory.service'); +const { consumeItemAtomic, addItemAtomic, addUniqueItemOnce, purgeEmptyEntries } = require('../services/inventory.service'); const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); +const { checkAndUnlockAchievements } = require('./reward.controller'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -15,6 +16,28 @@ function createError(message, statusCode = 400) { const MIN_LEVEL_FOR_CHEST = 11; +// Coffres cumulés à vie nécessaires pour débloquer le thème cosmétique +// Unique "Rouge Sang Unique" (Réglages → Apparence). +const CHESTS_FOR_BLOOD_SANG_THEME = 100; + +// ─── Cosmétiques Uniques réclamables (voir claimUniqueItem) ────────────────── +// itemType (inventaire) → { cosmetic (flag persisté), equip (auto-équipement) } +// equip est optionnel : uniquement pour les cosmétiques de cadre (forme/couleur). +const CLAIMABLE_COSMETICS = { + PROFILE_FRAME_BLOOD_BOND: { + cosmetic: 'FRAME_SHAPE_DRAGONFANG', + equip: { field: 'equippedFrame.shapeId', value: 'dragonfang' }, + }, + FRAME_COLOR_BLOOD_SANG: { + cosmetic: 'FRAME_COLOR_BLOODSANG', + equip: { field: 'equippedFrame.colorId', value: 'bloodsang' }, + }, + THEME_UNLOCK_BLOOD_SANG: { + cosmetic: 'THEME_BLOODSANG', + equip: null, // le thème de profil est une préférence locale (voir profileThemes.js front) + }, +}; + /** * Crédite atomiquement `amount` XP et recalcule level/rank si un palier est * franchi. Deux écritures ($inc puis $set conditionnel) mais aucune lecture @@ -93,6 +116,8 @@ const VALID_USE_ITEMS = Object.keys(ITEM_EFFECTS); * 1. Consomme 1 CHEST_KEY. * 2. Tire un item aléatoire via l'algorithme de tirage pondéré. * 3. Ajoute l'item à l'inventaire (incrémente si déjà présent). + * 4. Incrémente le compteur totalChestsOpened (trophées gradués, thème + * cosmétique Unique à 100 coffres) et débloque les trophées éligibles. */ exports.openChest = async (req, res, next) => { try { @@ -114,13 +139,94 @@ exports.openChest = async (req, res, next) => { const drawnItem = drawChestItem(); await addItemAtomic(req.user.id, drawnItem.itemType, drawnItem.rarity); - const finalUser = await purgeEmptyEntries(req.user.id); + + // Compteur à vie — indépendant de l'inventaire courant (purgé plus bas). + const afterCount = await User.findOneAndUpdate( + { _id: req.user.id }, + { $inc: { totalChestsOpened: 1 } }, + { returnDocument: 'after' }, + ).select('totalChestsOpened'); + + // Palier 100 coffres : octroie l'item Unique à réclamer (idempotent — + // addUniqueItemOnce ne ré-ajoute jamais si déjà possédé/réclamé). + let themeUnlockGranted = false; + if (afterCount && afterCount.totalChestsOpened >= CHESTS_FOR_BLOOD_SANG_THEME) { + const granted = await addUniqueItemOnce(req.user.id, 'THEME_UNLOCK_BLOOD_SANG', 'unique'); + themeUnlockGranted = Boolean(granted); + } + + const finalUser = await purgeEmptyEntries(req.user.id); + const newlyUnlocked = await checkAndUnlockAchievements(req.user.id); return res.status(200).json({ success: true, message: 'Coffre ouvert !', drawnItem, inventory: finalUser.inventory, + totalChestsOpened: afterCount ? afterCount.totalChestsOpened : null, + themeUnlockGranted, + newlyUnlocked, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// claimUniqueItem POST /api/inventory/claim +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Réclame un cosmétique Unique en attente dans l'inventaire (cadre "Lien de + * Sang", couleur "Rouge Sang Unique", thème "Rouge Sang Unique"…). + * + * Body : { itemType: string } — doit être une clé de CLAIMABLE_COSMETICS. + * + * Flux atomique : + * 1. Consomme 1 unité de l'item (garde anti double-spend habituelle). + * 2. Débloque définitivement le cosmétique ($addToSet unlockedCosmetics) + * et, pour les cadres, l'équipe automatiquement — en une seule écriture + * avec l'étape précédente pour éviter toute fenêtre incohérente. + * + * Idempotent au sens sécurité : sans l'item en inventaire, rien ne se passe. + */ +exports.claimUniqueItem = async (req, res, next) => { + try { + const { itemType } = req.body; + const spec = CLAIMABLE_COSMETICS[itemType]; + + if (!itemType || !spec) { + return next(createError( + `itemType invalide. Valeurs acceptées : ${Object.keys(CLAIMABLE_COSMETICS).join(', ')}.`, + 400, + )); + } + + const consumed = await consumeItemAtomic(req.user.id, itemType); + if (!consumed) { + const exists = await User.exists({ _id: req.user.id }); + if (!exists) return next(createError('Utilisateur introuvable.', 404)); + return next(createError('Vous ne possédez pas cet objet à réclamer.', 400)); + } + + const update = { $addToSet: { unlockedCosmetics: spec.cosmetic } }; + if (spec.equip) update.$set = { [spec.equip.field]: spec.equip.value }; + + const updated = await User.findOneAndUpdate( + { _id: req.user.id }, + update, + { returnDocument: 'after' }, + ).select('unlockedCosmetics equippedFrame inventory'); + + const finalUser = await purgeEmptyEntries(req.user.id); + + return res.status(200).json({ + success: true, + message: 'Cosmétique Unique débloqué !', + unlockedCosmetic: spec.cosmetic, + equippedFrame: updated.equippedFrame, + unlockedCosmetics: updated.unlockedCosmetics, + inventory: finalUser.inventory, }); } catch (err) { next(err); diff --git a/back/controllers/reward.controller.js b/back/controllers/reward.controller.js index 04c4491..e9bf1dc 100644 --- a/back/controllers/reward.controller.js +++ b/back/controllers/reward.controller.js @@ -2,6 +2,7 @@ const User = require('../models/User'); const Friendship = require('../models/Friendship'); +const { LOCAL_TROPHY_IDS } = require('../data/localTrophyCatalog'); // ─── Catalogue des trophées ─────────────────────────────────────────────────── // Source de vérité unique. Chaque entrée décrit un trophée du jeu. @@ -61,6 +62,43 @@ const ACHIEVEMENT_CATALOG = { hidden: false, }, + // ── Coffres (paliers gradués) ───────────────────────────────────────────── + CHEST_1: { + id: 'CHEST_1', + name: 'Premier Trésor', + description: 'Vous avez ouvert votre premier coffre.', + category: 'collection', + hidden: false, + }, + CHEST_10: { + id: 'CHEST_10', + name: 'Chasseur de Coffres', + description: 'Vous avez ouvert 10 coffres.', + category: 'collection', + hidden: false, + }, + CHEST_50: { + id: 'CHEST_50', + name: 'Pilleur Aguerri', + description: 'Vous avez ouvert 50 coffres.', + category: 'collection', + hidden: false, + }, + CHEST_100: { + id: 'CHEST_100', + name: 'Maître du Butin', + description: 'Vous avez ouvert 100 coffres.', + category: 'collection', + hidden: false, + }, + CHEST_200: { + id: 'CHEST_200', + name: 'Seigneur des Coffres', + description: 'Vous avez ouvert 200 coffres.', + category: 'collection', + hidden: false, + }, + // ── Social ──────────────────────────────────────────────────────────────── FIRST_REFERRAL: { id: 'FIRST_REFERRAL', @@ -146,6 +184,20 @@ async function checkAndUnlockAchievements(userId) { } } + // ── Trophées de coffres : paliers gradués sur totalChestsOpened ─────────── + const CHEST_ACHIEVEMENTS = [ + [1, 'CHEST_1'], + [10, 'CHEST_10'], + [50, 'CHEST_50'], + [100, 'CHEST_100'], + [200, 'CHEST_200'], + ]; + for (const [threshold, achievementId] of CHEST_ACHIEVEMENTS) { + if (!unlockedIds.has(achievementId) && user.totalChestsOpened >= threshold) { + tryUnlock(achievementId); + } + } + // ── Trophées sociaux ─────────────────────────────────────────────────────── // Premier parrainage : l'utilisateur a recruté au moins un autre joueur @@ -389,6 +441,54 @@ exports.checkAchievements = async (req, res, next) => { } }; +// ───────────────────────────────────────────────────────────────────────────── +// syncLocalAchievements PUT /api/rewards/achievements/sync +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Synchronise les trophées du catalogue LOCAL (V1, évalués côté client depuis + * les logs de séances AsyncStorage) vers le compte backend, pour qu'ils + * apparaissent dans le profil public consulté par les amis. + * + * Body : { ids: string[] } — filtré contre l'allowlist LOCAL_TROPHY_IDS : + * - Un id hors catalogue local (y compris un id du catalogue BACKEND, ex. + * "FRIENDSHIP_LEVEL_5") est silencieusement ignoré — jamais auto-octroyé. + * - Additif uniquement : ne retire jamais un trophée déjà synchronisé + * (le client peut renvoyer un sous-ensemble sans effacer l'historique). + */ +exports.syncLocalAchievements = async (req, res, next) => { + try { + const { ids } = req.body; + if (!Array.isArray(ids)) { + return next(createError('ids doit être un tableau.', 400)); + } + + const user = await User.findById(req.user.id).select('achievements'); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + const alreadyUnlocked = new Set(user.achievements.map((a) => a.achievementId)); + const validIds = ids.filter((id) => typeof id === 'string' && LOCAL_TROPHY_IDS.has(id)); + const toAdd = validIds.filter((id) => !alreadyUnlocked.has(id)); + const ignored = ids.filter((id) => !LOCAL_TROPHY_IDS.has(id)); + + if (toAdd.length > 0) { + await User.updateOne( + { _id: req.user.id }, + { $push: { achievements: { $each: toAdd.map((achievementId) => ({ achievementId })) } } }, + ); + } + + return res.status(200).json({ + success: true, + synced: toAdd, + added: toAdd.length, + ignored: ignored.length, + }); + } catch (err) { + next(err); + } +}; + // Exporté pour être importé depuis d'autres contrôleurs (openChest, acceptFriend…) exports.checkAndUnlockAchievements = checkAndUnlockAchievements; exports.ACHIEVEMENT_CATALOG = ACHIEVEMENT_CATALOG; diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index c861511..6055410 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -66,4 +66,23 @@ exports.updateFrame = async (req, res, next) => { } catch (error) { next(error); } +}; + +/** + * METTRE À JOUR LA VITRINE DE TROPHÉES + * Trophées mis en avant sur le profil public (max 3, catalogue combiné). + */ +exports.updateShowcase = async (req, res, next) => { + try { + const { achievementIds } = req.body; + const user = await userService.updateShowcase(req.user.id, achievementIds); + + res.status(200).json({ + success: true, + message: "Vitrine mise à jour avec succès", + showcasedAchievements: user.showcasedAchievements, + }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/back/data/localTrophyCatalog.js b/back/data/localTrophyCatalog.js new file mode 100644 index 0000000..1b7bbb4 --- /dev/null +++ b/back/data/localTrophyCatalog.js @@ -0,0 +1,166 @@ +'use strict'; + +// ─── Miroir backend du catalogue de trophées LOCAL du front ─────────────────── +// Source de vérité des CONDITIONS : front/src/data/trophyCatalog.js — les +// conditions s'évaluent côté client (elles dépendent des logs de séances +// AsyncStorage que le serveur ne possède pas). Le client synchronise les IDs +// débloqués via PUT /api/rewards/achievements/sync ; ce fichier ne porte que +// les MÉTADONNÉES d'affichage — mêmes noms de champs que le front +// (label/condition/epicDesc/gradientColors/tier) pour un rendu visuel +// PARFAITEMENT identique (TrophySlot est partagé, pas réimplémenté) — et sert +// d'allowlist pour la synchronisation. +// +// hidden: true → catégorie "secret" du front : masqué tant que non débloqué. + +const T = (id, category, icon, label, condition, epicDesc, color, gradientColors, tier, hidden = false) => + ({ id, category, icon, label, condition, epicDesc, color, gradientColors, tier, hidden }); + +const LOCAL_TROPHY_LIST = [ + // ── HÉRITAGE (6) ── + T('ignition', 'heritage', 'flame', 'Ignition', '1ère séance', + "La flamme s'allume. Votre premier pas dans l'arène — et rien ne sera jamais plus pareil.", + '#FE7439', ['#FF9A5C', '#FE7439', '#C44A10'], 'bronze'), + T('promise', 'heritage', 'medal', 'Promesse', 'Niveau 10', + "Le novice est mort. Vous avez prouvé que vous êtes là pour durer — la promesse est tenue.", + '#FFD700', ['#FFE566', '#FFD700', '#B8860B'], 'gold'), + T('apprenti', 'heritage', 'school', 'Apprenti', 'Niveau 25', + "Les bases sont posées. Chaque set vous a sculpté — vous n'êtes plus un débutant, vous êtes un apprenti.", + '#34D399', ['#6EE7B7', '#34D399', '#059669'], 'silver'), + T('centurion', 'heritage', 'trophy', 'Centurion', '50 séances', + "Votre volonté est d'acier. 50 combats menés avec honneur — les légions vous saluent.", + '#6E6AF0', ['#9B97FF', '#6E6AF0', '#3D3A9E'], 'platinum'), + T('veteran', 'heritage', 'shield-checkmark', 'Vétéran', 'Niveau 75', + "Trois quarts du chemin vers le sommet. Vous portez les cicatrices de centaines de batailles.", + '#3B82F6', ['#60A5FA', '#3B82F6', '#1D4ED8'], 'gold'), + T('demi_dieu', 'heritage', 'sparkles', 'Demi-Dieu', 'Niveau 150', + "150 niveaux d'ascension. Les mortels vous regardent avec crainte. Vous n'appartenez plus à leur monde.", + '#8B5CF6', ['#A78BFA', '#8B5CF6', '#5B21B6'], 'diamond'), + + // ── FORCE (7) ── + T('titan', 'force', 'barbell', 'Le Titan', '10 000 kg soulevés', + "Dix mille kilos. Un chiffre qui n'appartient qu'aux légendes du fer.", + '#DC2626', ['#EF4444', '#DC2626', '#991B1B'], 'gold'), + T('iron_will', 'force', 'shield', 'Iron Will', '3 jours consécutifs', + "Trois jours sans relâche. Quand votre corps criait stop, vous avez choisi de continuer.", + '#B45309', ['#D97706', '#B45309', '#78350F'], 'silver'), + T('machine', 'force', 'flash', 'La Machine', '25 sets en une séance', + "Vingt-cinq séries en un seul souffle. Vous n'êtes pas humain, vous êtes une machine.", + '#F59E0B', ['#FDE68A', '#F59E0B', '#B45309'], 'silver'), + T('legionnaire', 'force', 'star', 'Légionnaire', '100 séances', + "Cent batailles. Cent victoires sur vous-même. Les légions de Rome vous auraient honoré.", + '#EAB308', ['#FDE047', '#EAB308', '#854D0E'], 'platinum'), + T('colossus', 'force', 'body', 'Colosse', '50 000 kg soulevés', + "Cinquante mille kilos déplacés par votre seule volonté. Vous êtes une force de la nature.", + '#991B1B', ['#DC2626', '#991B1B', '#450A0A'], 'diamond'), + T('forge', 'force', 'hammer', 'La Forge', '500 sets au total', + "Cinq cents séries. Votre corps a été forgé à la chaleur du travail acharné.", + '#C2410C', ['#EA580C', '#C2410C', '#7C2D12'], 'silver'), + T('volume_king', 'force', 'analytics', 'Roi du Volume', '100 000 kg soulevés', + "Cent mille kilos. Le roi du volume tient son trône.", + '#7C3AED', ['#8B5CF6', '#7C3AED', '#4C1D95'], 'diamond'), + + // ── EXPLORATION (6) ── + T('polyvalent', 'exploration', 'grid', 'Polyvalent', '5 groupes musculaires', + "Pecs, dos, jambes, épaules, bras — vous ne laissez aucun muscle au repos.", + '#22C55E', ['#4ADE80', '#22C55E', '#15803D'], 'silver'), + T('marathonien', 'exploration', 'time', 'Marathonien', 'Séance ≥ 60 min', + "Une heure dans l'arène. Quand les autres partaient après 30 minutes, vous étiez encore là.", + '#0EA5E9', ['#38BDF8', '#0EA5E9', '#0369A1'], 'bronze'), + T('demi_legende', 'exploration', 'rocket', 'Demi-Légende', 'Niveau 50', + "La moitié du chemin vers le sommet. Peu y arrivent — vous y êtes.", + '#3B82F6', ['#60A5FA', '#3B82F6', '#1D4ED8'], 'gold'), + T('xp_millionaire', 'exploration', 'infinite', 'XP Millionnaire', '1 000 000 XP cumulés', + "Un million de points d'expérience. Une vie entière de sueur, de fer et de détermination.", + '#A855F7', ['#C084FC', '#A855F7', '#7E22CE'], 'diamond'), + T('speed_demon', 'exploration', 'speedometer', 'Speed Demon', 'Séance complète ≤ 20 min', + "Vingt minutes. Efficace, explosif, précis. Quand les autres finissent leur échauffement, vous êtes déjà sous la douche.", + '#F97316', ['#FB923C', '#F97316', '#C2410C'], 'bronze'), + T('ultra_marathon', 'exploration', 'hourglass', 'Ultra-Marathon', 'Séance ≥ 90 min', + "Quatre-vingt-dix minutes de pur effort. Quand la plupart abandonnent, vous n'avez pas encore commencé.", + '#0891B2', ['#22D3EE', '#0891B2', '#164E63'], 'silver'), + + // ── SECRET (8) — masqués tant que non débloqués ── + T('night_owl', 'secret', 'moon', 'Oiseau de Nuit', 'Séance entre 23h et 4h', + "Pendant que le monde dort, vous forgez votre corps dans le silence.", + '#6366F1', ['#818CF8', '#6366F1', '#3730A3'], 'silver', true), + T('determined', 'secret', 'calendar', 'Déterminé', 'Séance le 1er janvier', + "Quand les autres font des vœux, vous faites des sets.", + '#F43F5E', ['#FB7185', '#F43F5E', '#BE123C'], 'gold', true), + T('early_bird', 'secret', 'sunny', 'Lève-Tôt', 'Séance avant 6h du matin', + "Le soleil n'est pas encore levé, et vous êtes déjà en sueur.", + '#F97316', ['#FB923C', '#F97316', '#C2410C'], 'bronze', true), + T('dawn_warrior', 'secret', 'partly-sunny', "Guerrier de l'Aube", 'Séance avant 5h du matin', + "4h58. L'obscurité n'a pas encore capitulé, mais vous, oui.", + '#F59E0B', ['#FCD34D', '#F59E0B', '#92400E'], 'silver', true), + T('streak_hunter', 'secret', 'flame', 'Chasseur de Streak', 'Streak ≥ 7 jours', + "Sept jours sans interruption. Le feu de votre volonté brûle plus fort que jamais.", + '#FB923C', ['#FDBA74', '#FB923C', '#EA580C'], 'silver', true), + T('ultra_streak', 'secret', 'infinite', 'Ultra Streak', 'Streak ≥ 30 jours', + "Trente jours de constance absolue. Vous avez transcendé la discipline pour atteindre l'obsession.", + '#FFD700', ['#FDE68A', '#FFD700', '#B45309'], 'diamond', true), + T('midnight_wolf', 'secret', 'cloudy-night', 'Le Loup', 'Séance entre 0h et 1h', + "Minuit passé. Les loups chassent quand le troupeau dort.", + '#4338CA', ['#6366F1', '#4338CA', '#1E1B4B'], 'silver', true), + T('athly_god', 'secret', 'planet', 'ATHLY GOD', 'Niveau 200', + "Le sommet absolu. Vous n'êtes plus un athlète — vous êtes une légende vivante. Le trône vous appartient.", + '#FFD700', ['#FDE68A', '#FFD700', '#92400E'], 'diamond', true), + + // ── CORPS (5) ── + T('corps_bronze', 'corps', 'fitness', 'Initié Poids Corps', '50 sets complétés', + "Cinquante séries avec votre propre corps. Pas de barres, pas de charges — juste vous contre la gravité.", + '#CD7F32', ['#E8A060', '#CD7F32', '#6B3A1A'], 'bronze'), + T('corps_silver', 'corps', 'walk', 'Guerrier Poids Corps', '150 sets complétés', + "Cent cinquante séries. Votre poids corporel est devenu votre outil de sculpture le plus précis.", + '#D1D5DB', ['#F3F4F6', '#D1D5DB', '#9CA3AF'], 'silver'), + T('corps_gold', 'corps', 'barbell', 'Maître Poids Corps', '400 sets complétés', + "Quatre cents séries. La maîtrise s'est installée.", + '#FFD700', ['#FDE047', '#FFD700', '#B45309'], 'gold'), + T('corps_platinum', 'corps', 'shield-half', 'Élite Poids Corps', '800 sets complétés', + "Huit cents séries. Vous portez votre corps comme une armure.", + '#E5E7EB', ['#FFFFFF', '#E5E7EB', '#9CA3AF'], 'platinum'), + T('corps_diamond', 'corps', 'diamond', 'Légende Poids Corps', '1 500 sets complétés', + "Quinze cents séries. Un monument de discipline et de force pure.", + '#60A5FA', ['#BFDBFE', '#60A5FA', '#1D4ED8'], 'diamond'), + + // ── RÉGULARITÉ (5) ── + T('reg_3m', 'regularite', 'calendar-outline', 'Constance 3 Mois', 'Séances sur 3 mois', + "Trois mois d'entraînement. Pas une mode — une véritable habitude.", + '#10B981', ['#34D399', '#10B981', '#065F46'], 'bronze'), + T('reg_6m', 'regularite', 'time-outline', 'Constance 6 Mois', 'Séances sur 6 mois', + "Six mois. La moitié d'une année dédiée au progrès.", + '#0D9488', ['#14B8A6', '#0D9488', '#134E4A'], 'silver'), + T('reg_9m', 'regularite', 'medal-outline', 'Constance 9 Mois', 'Séances sur 9 mois', + "Neuf mois d'engagement total. Il faut neuf mois pour renaître.", + '#0891B2', ['#22D3EE', '#0891B2', '#0C4A6E'], 'gold'), + T('reg_12m', 'regularite', 'trophy-outline', 'Constance 12 Mois', 'Séances sur 12 mois', + "Un an. 365 jours de transformation. Vous êtes encore debout.", + '#7C3AED', ['#A78BFA', '#7C3AED', '#3B0764'], 'platinum'), + T('iron_discipline', 'regularite', 'repeat', 'Discipline de Fer', '5 semaines avec 1+ séance', + "Cinq semaines consécutives sans interruption.", + '#6366F1', ['#818CF8', '#6366F1', '#312E81'], 'silver'), + + // ── SOCIAL (2) ── + T('first_friend', 'social', 'people', 'Premier Ami', 'Ajouter un ami', + "Les grandes épopées ne se vivent pas seules. Votre premier compagnon d'armes vous attend.", + '#EC4899', ['#F472B6', '#EC4899', '#9D174D'], 'bronze'), + T('mentor', 'social', 'people-circle', 'Mentor', 'Inspirer 5 amis', + "Vous avez allumé la flamme chez cinq autres — vous êtes plus qu'un athlète, vous êtes un mentor.", + '#8B5CF6', ['#A78BFA', '#8B5CF6', '#4C1D95'], 'gold'), + + // ── SPÉCIAL (1) ── + T('athly_birthday', 'special', 'gift', 'Anniversaire Athly', 'Séance le 13 mai', + "Le jour où Athly est né, vous étiez là — à suer, à pousser, à vous dépasser.", + '#FE7439', ['#FF9A5C', '#FE7439', '#C44A10'], 'gold'), + + // ── ULTIME (1) ── + T('souverain_absolu', 'ultime', 'infinite', 'Souverain Absolu', 'Tous les trophées débloqués', + "Il n'existe pas de plus grand accomplissement. Vous avez tout conquis, tout maîtrisé, tout surpassé. L'empire d'Athly vous appartient — et l'univers entier s'incline devant vous.", + '#FFD700', ['#FFFACD', '#FFD700', '#FF8C00', '#C44A10'], 'diamond'), +]; + +// Indexé par id, même forme que ACHIEVEMENT_CATALOG (reward.controller.js) +const LOCAL_TROPHY_CATALOG = Object.fromEntries(LOCAL_TROPHY_LIST.map((t) => [t.id, t])); + +const LOCAL_TROPHY_IDS = new Set(Object.keys(LOCAL_TROPHY_CATALOG)); + +module.exports = { LOCAL_TROPHY_CATALOG, LOCAL_TROPHY_IDS }; diff --git a/back/jest.config.js b/back/jest.config.js index 3b5ea30..cd16a2f 100644 --- a/back/jest.config.js +++ b/back/jest.config.js @@ -3,6 +3,9 @@ module.exports = { // Base MongoDB en mémoire : les tests ne touchent jamais une vraie base globalSetup: '/tests/globalSetup.js', globalTeardown: '/tests/globalTeardown.js', - // globalSetup/globalTeardown ne sont pas des suites de tests - testPathIgnorePatterns: ['/node_modules/', '/tests/globalSetup.js', '/tests/globalTeardown.js'], + // Mock du service d'email (voir tests/setupMocks.js) : aucun test n'envoie + // jamais de vrai email, quel que soit le chemin de code qu'il exerce. + setupFilesAfterEnv: ['/tests/setupMocks.js'], + // globalSetup/globalTeardown/setupMocks ne sont pas des suites de tests + testPathIgnorePatterns: ['/node_modules/', '/tests/globalSetup.js', '/tests/globalTeardown.js', '/tests/setupMocks.js'], }; diff --git a/back/models/StreakGroup.js b/back/models/StreakGroup.js index 782a919..5d82241 100644 --- a/back/models/StreakGroup.js +++ b/back/models/StreakGroup.js @@ -31,6 +31,12 @@ const StreakGroupSchema = new mongoose.Schema( // Dernier jour où tous les membres ont validé une séance. // null = groupe créé mais aucune journée validée encore. lastValidatedDate: { type: Date, default: null }, + + // Garde d'octroi unique : passe à true la première fois que ce groupe + // atteint 30 jours de streak à 5 membres (récompense cosmétique "Rouge + // Sang Unique"). Ne redescend jamais, même si la streak est ensuite + // remise à 0 — évite un ré-octroi si le groupe rebâtit une streak. + bloodSangAwarded: { type: Boolean, default: false }, }, { timestamps: true } ); diff --git a/back/models/User.js b/back/models/User.js index a9417dd..7d76f25 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -26,6 +26,8 @@ const InventoryItemSchema = new mongoose.Schema( "LEVEL_COUPON", // Coupon de niveau : +1 level "CHEST_KEY", // Clé de coffre : ouvre un coffre "PROFILE_FRAME_BLOOD_BOND", // Cadre cosmétique Unique — niveau d'amitié 5, hors coffres + "FRAME_COLOR_BLOOD_SANG", // Couleur cosmétique Unique — streak groupe 30j à 5 membres, hors coffres + "THEME_UNLOCK_BLOOD_SANG", // Thème cosmétique Unique — 100 coffres ouverts, hors coffres ], }, rarity: { @@ -118,6 +120,17 @@ const UserSchema = new mongoose.Schema( // Cumul des minutes de séance — débloque des coffres à certains paliers totalWorkoutMinutes: { type: Number, default: 0, min: 0 }, + // Cumul du nombre de coffres ouverts (openChest) — indépendant des minutes + // de séance : sert de condition de déblocage (trophées gradués, thème + // cosmétique Rouge Sang Unique à 100 coffres). + totalChestsOpened: { type: Number, default: 0, min: 0 }, + + // Cosmétiques Uniques définitivement débloqués (réclamés depuis + // l'inventaire — voir inventory.controller.js → claimUniqueItem). + // Clés libres du catalogue front (BorderPicker.js / profileThemes.js) : + // ex. "FRAME_SHAPE_DRAGONFANG", "FRAME_COLOR_BLOODSANG", "THEME_BLOODSANG". + unlockedCosmetics: { type: [String], default: [] }, + // ── Parrainage V2 ───────────────────────────────────────────────────────── // Code unique généré à la création du compte (ex: "ATH-X7K2P") referralCode: { type: String, unique: true, sparse: true }, @@ -129,6 +142,11 @@ const UserSchema = new mongoose.Schema( // Tableau des trophées débloqués. Le catalogue complet vit dans reward.controller.js. achievements: { type: [AchievementEntrySchema], default: [] }, + // Trophées mis en avant sur le profil public (max 3, contrôlé par Joi côté + // validateur). Peut référencer un id du catalogue backend OU du miroir + // local (LOCAL_TROPHY_CATALOG) — voir user.service.js → updateShowcase. + showcasedAchievements: { type: [String], default: [] }, + // ── Cadre de profil équipé ───────────────────────────────────────────────── // Synchronisé depuis le choix local (useAvatarFrame.js) pour que les amis // voient le même cadre sur le profil public. shapeId/colorId sont des clés diff --git a/back/routes/debug.routes.js b/back/routes/debug.routes.js index 2d44521..e90fe19 100644 --- a/back/routes/debug.routes.js +++ b/back/routes/debug.routes.js @@ -13,7 +13,11 @@ router.use(auth); router.post('/sync-level', debug.syncLevel); // ── God Mode : sandbox de test (voir controllers/debug.controller.js) ──────── -router.post('/godmode/give-chests', debug.giveChests); -router.post('/godmode/mock-social', debug.mockSocial); +router.post('/godmode/give-chests', debug.giveChests); +router.post('/godmode/give-all-items', debug.giveAllItems); +router.post('/godmode/simulate-chests-opened', debug.simulateChestsOpened); +router.post('/godmode/simulate-referral', debug.simulateReferral); +router.post('/godmode/simulate-birthday', debug.simulateBirthday); +router.post('/godmode/mock-social', debug.mockSocial); module.exports = router; diff --git a/back/routes/inventory.routes.js b/back/routes/inventory.routes.js index 7ab7aed..9386170 100644 --- a/back/routes/inventory.routes.js +++ b/back/routes/inventory.routes.js @@ -14,4 +14,7 @@ router.post('/chest/open', inventory.openChest); // ── Consommables ────────────────────────────────────────────────────────────── router.post('/item/use', inventory.useItem); +// ── Cosmétiques Uniques (cadre/couleur/thème) ───────────────────────────────── +router.post('/claim', inventory.claimUniqueItem); + module.exports = router; diff --git a/back/routes/reward.routes.js b/back/routes/reward.routes.js index 2df2825..e11fb77 100644 --- a/back/routes/reward.routes.js +++ b/back/routes/reward.routes.js @@ -20,4 +20,8 @@ router.get('/achievements', reward.getUserAchievements); // (openChest, acceptFriendRequest, checkAndUpdateGroupStreaks…) router.post('/check', reward.checkAchievements); +// Synchronise les trophées du catalogue LOCAL (V1) débloqués côté client, +// pour qu'ils apparaissent sur le profil public consulté par les amis. +router.put('/achievements/sync', reward.syncLocalAchievements); + module.exports = router; diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 824a601..abe8dc1 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const userController = require("../controllers/user.controller"); const auth = require("../middleware/auth.middleware"); const validate = require("../middleware/validate.middleware"); -const { updateProfile, updateFrame } = require("../validators/user.validator"); +const { updateProfile, updateFrame, updateShowcase } = require("../validators/user.validator"); /** * ROUTES UTILISATEURS PROTEGEES @@ -19,6 +19,9 @@ router.put("/me", auth, validate(updateProfile), userController.updateMe); // Synchroniser le cadre de profil équipé (visible sur le profil public) router.put("/me/frame", auth, validate(updateFrame), userController.updateFrame); +// Mettre à jour la vitrine de trophées mis en avant (max 3) +router.put("/me/showcase", auth, validate(updateShowcase), userController.updateShowcase); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/user.service.js b/back/services/user.service.js index 9355c53..28354e8 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -67,6 +67,30 @@ class UserService { return updatedUser; } + /** + * Met à jour la vitrine de trophées mis en avant sur le profil public. + * Filtre contre le catalogue combiné (backend + miroir local) : impossible + * de mettre en avant un id inexistant. Le Joi validator borne déjà à 3 ids. + * @param {string} userId + * @param {string[]} achievementIds + */ + async updateShowcase(userId, achievementIds) { + const { ACHIEVEMENT_CATALOG } = require('../controllers/reward.controller'); + const { LOCAL_TROPHY_CATALOG } = require('../data/localTrophyCatalog'); + + const validIds = achievementIds.filter( + (id) => Object.prototype.hasOwnProperty.call(ACHIEVEMENT_CATALOG, id) || Object.prototype.hasOwnProperty.call(LOCAL_TROPHY_CATALOG, id), + ); + + const updatedUser = await User.findByIdAndUpdate( + userId, + { $set: { showcasedAchievements: validIds.slice(0, 3) } }, + { new: true, runValidators: true }, + ).select('showcasedAchievements'); + if (!updatedUser) throw new Error('Utilisateur non trouvé.'); + return updatedUser; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/services/workout.service.js b/back/services/workout.service.js index 01d61f3..63e5990 100644 --- a/back/services/workout.service.js +++ b/back/services/workout.service.js @@ -5,9 +5,10 @@ const { addItemAtomic } = require("./inventory.service"); // ── Coffres à l'effort (Brique II) ─────────────────────────────────────────── // 1 coffre (CHEST_KEY) tous les CHEST_MINUTES_THRESHOLD minutes de séance -// légitime cumulées. Le drop est verrouillé sous le niveau 11 (Rang Initié), +// légitime cumulées — soit 1 coffre toutes les ~2 séances pour une séance +// moyenne d'1h. Le drop est verrouillé sous le niveau 11 (Rang Initié), // comme l'ouverture des coffres. -const CHEST_MINUTES_THRESHOLD = 300; +const CHEST_MINUTES_THRESHOLD = 120; const MIN_LEVEL_FOR_CHEST_DROP = 11; /** diff --git a/back/tests/bloodSangRewards.test.js b/back/tests/bloodSangRewards.test.js new file mode 100644 index 0000000..e36f0f2 --- /dev/null +++ b/back/tests/bloodSangRewards.test.js @@ -0,0 +1,454 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const StreakGroup = require('../models/StreakGroup'); +const Workout = require('../models/Workout'); +const { xpForLevel, getRankForLevel } = require('../utils/levelHelpers'); + +// ───────────────────────────────────────────────────────────────────────────── +// Helper +// ───────────────────────────────────────────────────────────────────────────── + +async function createAndLoginUser(pseudo, email) { + await request(app) + .post('/api/auth/register') + .send({ pseudo, email, password: 'Password123!' }); + + await User.updateOne({ email }, { isVerified: true }); + + const loginRes = await request(app) + .post('/api/auth/login') + .send({ email, password: 'Password123!' }); + + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Suite principale +// ───────────────────────────────────────────────────────────────────────────── + +describe('Prestige visuel Rouge Sang Unique — cadre, couleur, thème, coffres, God Mode', () => { + let alice; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await StreakGroup.deleteMany({}); + await Workout.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await StreakGroup.deleteMany({}); + await Workout.deleteMany({}); + alice = await createAndLoginUser('AliceFit', 'alice@athly.fr'); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 1. POST /api/inventory/claim — claimUniqueItem + // ─────────────────────────────────────────────────────────────────────────── + describe('POST /api/inventory/claim — claimUniqueItem', () => { + + it('✅ Réclame le cadre "Lien de Sang" : consomme l\'item, débloque et équipe la forme Croc de Dragon', async () => { + await User.updateOne( + { _id: alice.userId }, + { $push: { inventory: { itemType: 'PROFILE_FRAME_BLOOD_BOND', rarity: 'unique', quantity: 1 } } }, + ); + + const res = await request(app) + .post('/api/inventory/claim') + .set('Authorization', `Bearer ${alice.token}`) + .send({ itemType: 'PROFILE_FRAME_BLOOD_BOND' }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.unlockedCosmetic).toBe('FRAME_SHAPE_DRAGONFANG'); + expect(res.body.equippedFrame.shapeId).toBe('dragonfang'); + + const user = await User.findById(alice.userId); + expect(user.unlockedCosmetics).toContain('FRAME_SHAPE_DRAGONFANG'); + expect(user.equippedFrame.shapeId).toBe('dragonfang'); + expect(user.inventory.find((i) => i.itemType === 'PROFILE_FRAME_BLOOD_BOND')).toBeUndefined(); + }); + + it('✅ Réclame la couleur "Rouge Sang Unique" : débloque et équipe la couleur bloodsang', async () => { + await User.updateOne( + { _id: alice.userId }, + { $push: { inventory: { itemType: 'FRAME_COLOR_BLOOD_SANG', rarity: 'unique', quantity: 1 } } }, + ); + + const res = await request(app) + .post('/api/inventory/claim') + .set('Authorization', `Bearer ${alice.token}`) + .send({ itemType: 'FRAME_COLOR_BLOOD_SANG' }); + + expect(res.statusCode).toBe(200); + expect(res.body.unlockedCosmetic).toBe('FRAME_COLOR_BLOODSANG'); + expect(res.body.equippedFrame.colorId).toBe('bloodsang'); + + const user = await User.findById(alice.userId); + expect(user.unlockedCosmetics).toContain('FRAME_COLOR_BLOODSANG'); + expect(user.equippedFrame.colorId).toBe('bloodsang'); + }); + + it('✅ Réclame le thème "Rouge Sang Unique" : débloque le flag sans toucher au cadre équipé', async () => { + await User.updateOne( + { _id: alice.userId }, + { + $push: { inventory: { itemType: 'THEME_UNLOCK_BLOOD_SANG', rarity: 'unique', quantity: 1 } }, + $set: { equippedFrame: { shapeId: 'hex', colorId: 'silver' } }, + }, + ); + + const res = await request(app) + .post('/api/inventory/claim') + .set('Authorization', `Bearer ${alice.token}`) + .send({ itemType: 'THEME_UNLOCK_BLOOD_SANG' }); + + expect(res.statusCode).toBe(200); + expect(res.body.unlockedCosmetic).toBe('THEME_BLOODSANG'); + + const user = await User.findById(alice.userId); + expect(user.unlockedCosmetics).toContain('THEME_BLOODSANG'); + // Le cadre équipé n'est pas affecté par le déblocage d'un thème. + expect(user.equippedFrame.shapeId).toBe('hex'); + expect(user.equippedFrame.colorId).toBe('silver'); + }); + + it('❌ 400 si l\'utilisateur ne possède pas l\'item à réclamer', async () => { + const res = await request(app) + .post('/api/inventory/claim') + .set('Authorization', `Bearer ${alice.token}`) + .send({ itemType: 'PROFILE_FRAME_BLOOD_BOND' }); + + expect(res.statusCode).toBe(400); + const user = await User.findById(alice.userId); + expect(user.unlockedCosmetics).toEqual([]); + }); + + it('❌ 400 si itemType n\'est pas un cosmétique réclamable (ex: consommable classique)', async () => { + await User.updateOne( + { _id: alice.userId }, + { $push: { inventory: { itemType: 'ENERGY_DRINK', rarity: 'common', quantity: 1 } } }, + ); + + const res = await request(app) + .post('/api/inventory/claim') + .set('Authorization', `Bearer ${alice.token}`) + .send({ itemType: 'ENERGY_DRINK' }); + + expect(res.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/inventory/claim').send({ itemType: 'PROFILE_FRAME_BLOOD_BOND' }); + expect(res.statusCode).toBe(401); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 2. openChest — totalChestsOpened, trophées gradués, déblocage thème 100 + // ─────────────────────────────────────────────────────────────────────────── + describe('openChest — compteur de coffres, trophées gradués et thème 100 coffres', () => { + + async function giveChestKeyAndSetLevel(extraFields = {}) { + await User.updateOne( + { _id: alice.userId }, + { + $set: { level: 11, xp: xpForLevel(11), rank: getRankForLevel(11), ...extraFields }, + $push: { inventory: { itemType: 'CHEST_KEY', rarity: 'common', quantity: 1 } }, + }, + ); + } + + it('✅ Premier coffre ouvert débloque le trophée CHEST_1', async () => { + await giveChestKeyAndSetLevel(); + + const res = await request(app) + .post('/api/inventory/chest/open') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.totalChestsOpened).toBe(1); + expect(res.body.newlyUnlocked).toContain('CHEST_1'); + expect(res.body.newlyUnlocked).not.toContain('CHEST_10'); + expect(res.body.themeUnlockGranted).toBe(false); + }); + + it('✅ Franchir 100 coffres octroie l\'item Unique de thème + le trophée CHEST_100', async () => { + await giveChestKeyAndSetLevel({ totalChestsOpened: 99 }); + + const res = await request(app) + .post('/api/inventory/chest/open') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.totalChestsOpened).toBe(100); + expect(res.body.themeUnlockGranted).toBe(true); + expect(res.body.newlyUnlocked).toContain('CHEST_100'); + + const user = await User.findById(alice.userId); + const themeItem = user.inventory.find((i) => i.itemType === 'THEME_UNLOCK_BLOOD_SANG'); + expect(themeItem).toBeDefined(); + expect(themeItem.rarity).toBe('unique'); + expect(themeItem.quantity).toBe(1); + }); + + it('🔒 Idempotent : l\'item de thème n\'est jamais dupliqué au-delà de 100 coffres', async () => { + await User.updateOne( + { _id: alice.userId }, + { + $set: { + level: 11, xp: xpForLevel(11), rank: getRankForLevel(11), + totalChestsOpened: 105, + inventory: [ + { itemType: 'CHEST_KEY', rarity: 'common', quantity: 1 }, + { itemType: 'THEME_UNLOCK_BLOOD_SANG', rarity: 'unique', quantity: 1 }, + ], + }, + }, + ); + + const res = await request(app) + .post('/api/inventory/chest/open') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.themeUnlockGranted).toBe(false); + + const user = await User.findById(alice.userId); + const themeItems = user.inventory.filter((i) => i.itemType === 'THEME_UNLOCK_BLOOD_SANG'); + expect(themeItems).toHaveLength(1); + expect(themeItems[0].quantity).toBe(1); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 3. checkAndUpdateGroupStreaks — couleur Rouge Sang Unique à 30j / 5 membres + // ─────────────────────────────────────────────────────────────────────────── + describe('checkAndUpdateGroupStreaks — couleur "Rouge Sang Unique" (30j de streak à 5 membres)', () => { + let members; + + beforeEach(async () => { + const bob = await createAndLoginUser('Bob', 'bob@athly.fr'); + const carol = await createAndLoginUser('Carol', 'carol@athly.fr'); + const dave = await createAndLoginUser('Dave', 'dave@athly.fr'); + const eve = await createAndLoginUser('Eve', 'eve@athly.fr'); + members = [alice, bob, carol, dave, eve]; + }); + + async function markAllValidatedToday() { + const today = new Date(); + await Promise.all(members.map((m) => Workout.create({ user: m.userId, status: 'finished', date: today }))); + } + + it('✅ Groupe de 5 membres franchissant 30 jours : chaque membre reçoit l\'item Unique', async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const group = await StreakGroup.create({ + members: members.map((m) => m.userId), + currentStreak: 29, + lastValidatedDate: yesterday, + }); + await markAllValidatedToday(); + + const res = await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.currentStreak).toBe(30); + expect(res.body.bloodSangUnlocked).toBe(true); + + for (const m of members) { + const user = await User.findById(m.userId); + const item = user.inventory.find((i) => i.itemType === 'FRAME_COLOR_BLOOD_SANG'); + expect(item).toBeDefined(); + expect(item.rarity).toBe('unique'); + expect(item.quantity).toBe(1); + } + + const updatedGroup = await StreakGroup.findById(group._id); + expect(updatedGroup.bloodSangAwarded).toBe(true); + }); + + it('🔒 Un groupe de 4 membres à 30 jours ne débloque rien (taille maximale requise)', async () => { + const fourMembers = members.slice(0, 4); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const group = await StreakGroup.create({ + members: fourMembers.map((m) => m.userId), + currentStreak: 29, + lastValidatedDate: yesterday, + }); + const today = new Date(); + await Promise.all(fourMembers.map((m) => Workout.create({ user: m.userId, status: 'finished', date: today }))); + + const res = await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.currentStreak).toBe(30); + expect(res.body.bloodSangUnlocked).toBe(false); + + const user = await User.findById(alice.userId); + expect(user.inventory.find((i) => i.itemType === 'FRAME_COLOR_BLOOD_SANG')).toBeUndefined(); + }); + + it('🔒 Idempotent : un groupe déjà récompensé ne redonne pas l\'item en continuant sa streak', async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const group = await StreakGroup.create({ + members: members.map((m) => m.userId), + currentStreak: 30, + lastValidatedDate: yesterday, + bloodSangAwarded: true, + }); + await markAllValidatedToday(); + + const res = await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.currentStreak).toBe(31); + expect(res.body.bloodSangUnlocked).toBe(false); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 4. God Mode — give-all-items + // ─────────────────────────────────────────────────────────────────────────── + describe('POST /api/debug/godmode/give-all-items', () => { + + it('✅ Injecte 1 exemplaire de chaque itemType existant (consommables + cosmétiques Uniques)', async () => { + const res = await request(app) + .post('/api/debug/godmode/give-all-items') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + + const expectedTypes = User.schema.path('inventory').schema.path('itemType').enumValues; + expect(res.body.inventory).toHaveLength(expectedTypes.length); + + for (const itemType of expectedTypes) { + const entry = res.body.inventory.find((i) => i.itemType === itemType); + expect(entry).toBeDefined(); + expect(entry.quantity).toBe(1); + } + + // Les 3 cosmétiques Uniques sont bien inclus + expect(res.body.inventory.map((i) => i.itemType)).toEqual( + expect.arrayContaining(['PROFILE_FRAME_BLOOD_BOND', 'FRAME_COLOR_BLOOD_SANG', 'THEME_UNLOCK_BLOOD_SANG']), + ); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/give-all-items'); + expect(res.statusCode).toBe(401); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 5. God Mode — simulateurs de trophées backend difficiles à déclencher + // ─────────────────────────────────────────────────────────────────────────── + describe('POST /api/debug/godmode/simulate-chests-opened', () => { + + it('✅ Incrémente totalChestsOpened et débloque les trophées de palier franchis', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-chests-opened') + .set('Authorization', `Bearer ${alice.token}`) + .send({ amount: 10 }); + + expect(res.statusCode).toBe(200); + expect(res.body.totalChestsOpened).toBe(10); + expect(res.body.newlyUnlocked).toEqual(expect.arrayContaining(['CHEST_1', 'CHEST_10'])); + expect(res.body.themeUnlockGranted).toBe(false); + + const user = await User.findById(alice.userId); + expect(user.totalChestsOpened).toBe(10); + }); + + it('✅ Octroie l\'item de thème Rouge Sang au palier 100', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-chests-opened') + .set('Authorization', `Bearer ${alice.token}`) + .send({ amount: 100 }); + + expect(res.statusCode).toBe(200); + expect(res.body.themeUnlockGranted).toBe(true); + expect(res.body.newlyUnlocked).toContain('CHEST_100'); + + const user = await User.findById(alice.userId); + expect(user.inventory.find((i) => i.itemType === 'THEME_UNLOCK_BLOOD_SANG')).toBeDefined(); + }); + + it('❌ 400 si amount hors bornes (1–250)', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-chests-opened') + .set('Authorization', `Bearer ${alice.token}`) + .send({ amount: 500 }); + expect(res.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/simulate-chests-opened').send({ amount: 1 }); + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/debug/godmode/simulate-referral', () => { + + it('✅ Crée un filleul factice et débloque FIRST_REFERRAL', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-referral') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(201); + expect(res.body.newlyUnlocked).toContain('FIRST_REFERRAL'); + + const referred = await User.exists({ referredBy: alice.userId }); + expect(referred).toBeTruthy(); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/simulate-referral'); + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/debug/godmode/simulate-birthday', () => { + + it('✅ Force la date de naissance à aujourd\'hui et débloque les 2 trophées anniversaire', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-birthday') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.newlyUnlocked).toEqual(expect.arrayContaining(['BIRTHDAY_SET', 'BIRTHDAY_CELEBRATED'])); + + const user = await User.findById(alice.userId); + expect(user.isBirthdateSet).toBe(true); + expect(user.lastBirthdayRewardedYear).toBe(new Date().getUTCFullYear()); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/simulate-birthday'); + expect(res.statusCode).toBe(401); + }); + }); +}); diff --git a/back/tests/setupMocks.js b/back/tests/setupMocks.js new file mode 100644 index 0000000..5066653 --- /dev/null +++ b/back/tests/setupMocks.js @@ -0,0 +1,17 @@ +'use strict'; + +// Mock global du service d'email pour TOUTE la suite de tests. +// +// email.service.js ouvre une vraie connexion SMTP (Brevo) dès son chargement +// (transporter.verify()) et envoie de vrais emails à chaque inscription/mot de +// passe oublié. Comme quasiment tous les tests créent des utilisateurs via +// register() (helper createAndLoginUser), lancer la suite complète sans ce +// mock épuise le quota gratuit d'envoi en quelques minutes. +// +// Chargé via jest.config.js → setupFilesAfterEnv : exécuté avant chaque +// fichier de test, dans le même registre de modules — email.service.js +// n'est donc jamais réellement chargé ni sa connexion SMTP jamais ouverte. +jest.mock('../services/email.service', () => ({ + sendVerificationEmail: jest.fn().mockResolvedValue({ mocked: true }), + sendResetPasswordEmail: jest.fn().mockResolvedValue({ mocked: true }), +})); diff --git a/back/tests/socialEngine.test.js b/back/tests/socialEngine.test.js index d1f13b5..9d8277c 100644 --- a/back/tests/socialEngine.test.js +++ b/back/tests/socialEngine.test.js @@ -257,18 +257,18 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { .send({ durationSeconds }); } - it('✅ Franchir un palier de 300 min au niveau 11+ attribue une CHEST_KEY', async () => { + it('✅ Franchir un palier de 120 min au niveau 11+ attribue une CHEST_KEY', async () => { await User.updateOne( { _id: alice.userId }, - { level: 11, xp: 2000, totalWorkoutMinutes: 290 }, + { level: 11, xp: 2000, totalWorkoutMinutes: 100 }, ); - const res = await finalizeWorkoutOf(alice, 1200); // +20 min → 310 total + const res = await finalizeWorkoutOf(alice, 1200); // +20 min → 120 total expect(res.statusCode).toBe(200); expect(res.body.stats.chestsAwarded).toBe(1); const user = await User.findById(alice.userId); - expect(user.totalWorkoutMinutes).toBe(310); + expect(user.totalWorkoutMinutes).toBe(120); const key = user.inventory.find((i) => i.itemType === 'CHEST_KEY'); expect(key).toBeDefined(); expect(key.quantity).toBe(1); @@ -277,7 +277,7 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { it('🔒 Sous le niveau 11 : les minutes s\'accumulent mais AUCUN coffre ne drop', async () => { await User.updateOne( { _id: alice.userId }, - { level: 5, xp: 500, totalWorkoutMinutes: 290 }, + { level: 5, xp: 500, totalWorkoutMinutes: 100 }, ); const res = await finalizeWorkoutOf(alice, 1200); @@ -285,7 +285,7 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { expect(res.body.stats.chestsAwarded).toBe(0); const user = await User.findById(alice.userId); - expect(user.totalWorkoutMinutes).toBe(310); + expect(user.totalWorkoutMinutes).toBe(120); expect(user.inventory.find((i) => i.itemType === 'CHEST_KEY')).toBeUndefined(); }); diff --git a/back/tests/trophyUnification.test.js b/back/tests/trophyUnification.test.js new file mode 100644 index 0000000..a9c1d52 --- /dev/null +++ b/back/tests/trophyUnification.test.js @@ -0,0 +1,239 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const Friendship = require('../models/Friendship'); +const { LOCAL_TROPHY_IDS } = require('../data/localTrophyCatalog'); +const { ACHIEVEMENT_CATALOG } = require('../controllers/reward.controller'); + +// ───────────────────────────────────────────────────────────────────────────── +// Helper +// ───────────────────────────────────────────────────────────────────────────── + +async function createAndLoginUser(pseudo, email) { + await request(app) + .post('/api/auth/register') + .send({ pseudo, email, password: 'Password123!' }); + + await User.updateOne({ email }, { isVerified: true }); + + const loginRes = await request(app) + .post('/api/auth/login') + .send({ email, password: 'Password123!' }); + + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +async function makeFriends(a, b) { + return Friendship.create({ requester: a, recipient: b, status: 'accepted' }); +} + +const FULL_SIZE = Object.keys(ACHIEVEMENT_CATALOG).length + LOCAL_TROPHY_IDS.size; + +// ───────────────────────────────────────────────────────────────────────────── +// Suite principale +// ───────────────────────────────────────────────────────────────────────────── + +describe('Unification des catalogues de trophées — sync local, vitrine, profil ami', () => { + let alice, bob; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + alice = await createAndLoginUser('Alice', 'alice@athly.fr'); + bob = await createAndLoginUser('Bob', 'bob@athly.fr'); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 1. PUT /api/rewards/achievements/sync + // ─────────────────────────────────────────────────────────────────────────── + describe('PUT /api/rewards/achievements/sync — syncLocalAchievements', () => { + + it('✅ Ajoute les ids valides du catalogue local', async () => { + const res = await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['ignition', 'titan', 'night_owl'] }); + + expect(res.statusCode).toBe(200); + expect(res.body.added).toBe(3); + expect(res.body.synced).toEqual(expect.arrayContaining(['ignition', 'titan', 'night_owl'])); + + const user = await User.findById(alice.userId); + const ids = user.achievements.map((a) => a.achievementId); + expect(ids).toEqual(expect.arrayContaining(['ignition', 'titan', 'night_owl'])); + }); + + it("🔒 Ignore les ids hors catalogue local (y compris les ids du catalogue backend)", async () => { + const res = await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['ignition', 'FRIENDSHIP_LEVEL_5', 'id_bidon'] }); + + expect(res.statusCode).toBe(200); + expect(res.body.added).toBe(1); + expect(res.body.ignored).toBe(2); + + const user = await User.findById(alice.userId); + const ids = user.achievements.map((a) => a.achievementId); + expect(ids).toContain('ignition'); + expect(ids).not.toContain('FRIENDSHIP_LEVEL_5'); + expect(ids).not.toContain('id_bidon'); + }); + + it('✅ Idempotent : rejouer avec les mêmes ids ne duplique rien', async () => { + await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['ignition'] }); + + const res = await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['ignition'] }); + + expect(res.body.added).toBe(0); + + const user = await User.findById(alice.userId); + const count = user.achievements.filter((a) => a.achievementId === 'ignition').length; + expect(count).toBe(1); + }); + + it('✅ Additif uniquement : ne retire jamais un trophée déjà synchronisé', async () => { + await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['ignition', 'titan'] }); + + await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: ['promise'] }); + + const user = await User.findById(alice.userId); + const ids = user.achievements.map((a) => a.achievementId); + expect(ids).toEqual(expect.arrayContaining(['ignition', 'titan', 'promise'])); + }); + + it('❌ 400 si ids n\'est pas un tableau', async () => { + const res = await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${alice.token}`) + .send({ ids: 'ignition' }); + expect(res.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).put('/api/rewards/achievements/sync').send({ ids: [] }); + expect(res.statusCode).toBe(401); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 2. Profil ami — catalogue combiné (backend + local) + // ─────────────────────────────────────────────────────────────────────────── + describe('GET /api/friends/profile/:friendId — catalogue combiné', () => { + + it(`✅ Expose les ${FULL_SIZE} trophées (backend + local) avec les bons statuts`, async () => { + await makeFriends(alice.userId, bob.userId); + await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${bob.token}`) + .send({ ids: ['titan', 'night_owl'] }); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.profile.achievements).toHaveLength(FULL_SIZE); + expect(res.body.profile.achievementsStats.total).toBe(FULL_SIZE); + + const ids = res.body.profile.achievements.map((a) => a.id); + expect(ids).toContain('FRIENDSHIP_LEVEL_5'); // backend + expect(ids).toContain('titan'); // local + + const titan = res.body.profile.achievements.find((a) => a.id === 'titan'); + expect(titan.unlocked).toBe(true); + expect(titan.label).toBe('Le Titan'); + + // Trophée secret local débloqué → révélé ; secret verrouillé → masqué + const nightOwl = res.body.profile.achievements.find((a) => a.id === 'night_owl'); + expect(nightOwl.unlocked).toBe(true); + expect(nightOwl.label).toBe('Oiseau de Nuit'); + const ultraStreak = res.body.profile.achievements.find((a) => a.id === 'ultra_streak'); + expect(ultraStreak.unlocked).toBe(false); + expect(ultraStreak.name).toBe('???'); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 3. PUT /api/users/me/showcase + // ─────────────────────────────────────────────────────────────────────────── + describe('PUT /api/users/me/showcase — updateShowcase', () => { + + it('✅ Enregistre la vitrine (max 3), filtre les ids inconnus', async () => { + const res = await request(app) + .put('/api/users/me/showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ achievementIds: ['ignition', 'titan', 'id_bidon'] }); + + expect(res.statusCode).toBe(200); + expect(res.body.showcasedAchievements).toEqual(['ignition', 'titan']); + }); + + it("✅ Accepte aussi un id du catalogue backend (ex: FRIENDSHIP_LEVEL_5)", async () => { + const res = await request(app) + .put('/api/users/me/showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ achievementIds: ['FRIENDSHIP_LEVEL_5'] }); + + expect(res.statusCode).toBe(200); + expect(res.body.showcasedAchievements).toEqual(['FRIENDSHIP_LEVEL_5']); + }); + + it('❌ 400 si plus de 3 ids (Joi)', async () => { + const res = await request(app) + .put('/api/users/me/showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ achievementIds: ['ignition', 'titan', 'promise', 'apprenti'] }); + expect(res.statusCode).toBe(400); + }); + + it("✅ La vitrine de l'ami est exposée, restreinte aux trophées effectivement débloqués", async () => { + await makeFriends(alice.userId, bob.userId); + + // Bob débloque uniquement 'ignition', mais met en vitrine ignition + titan + await request(app) + .put('/api/rewards/achievements/sync') + .set('Authorization', `Bearer ${bob.token}`) + .send({ ids: ['ignition'] }); + await request(app) + .put('/api/users/me/showcase') + .set('Authorization', `Bearer ${bob.token}`) + .send({ achievementIds: ['ignition', 'titan'] }); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + // titan non débloqué → exclu de la vitrine visible malgré la préférence enregistrée + expect(res.body.profile.showcasedAchievements).toEqual(['ignition']); + }); + }); +}); diff --git a/back/validators/user.validator.js b/back/validators/user.validator.js index 6a67a27..d253ddd 100644 --- a/back/validators/user.validator.js +++ b/back/validators/user.validator.js @@ -22,6 +22,10 @@ const userSchemas = { shapeId: Joi.string().max(40).required(), colorId: Joi.string().max(40).required(), }), + + updateShowcase: Joi.object({ + achievementIds: Joi.array().items(Joi.string().max(60)).max(3).required(), + }), }; module.exports = userSchemas; \ No newline at end of file diff --git a/front/src/components/profile/AchievementShowcase.js b/front/src/components/profile/AchievementShowcase.js index 1f183f1..22a94fd 100644 --- a/front/src/components/profile/AchievementShowcase.js +++ b/front/src/components/profile/AchievementShowcase.js @@ -1,17 +1,21 @@ -import React, { useRef, useEffect } from 'react'; -import { View, Text, StyleSheet, Animated } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, Modal, ScrollView, TouchableOpacity, Pressable, Dimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { RARITY_META } from '../../services/inventory.service'; +import { FeaturedModal, TrophyIcon } from './TrophySlot'; // ─── AchievementShowcase ────────────────────────────────────────────────────── -// Vitrine des trophées backend V2 (reward.controller.js ACHIEVEMENT_CATALOG) : -// anniversaire, collection d'objets, social. Distinct du système de trophées -// V1 100% local (trophyCatalog.js / TrophyGrid) qui n'est pas consultable -// pour un tiers faute d'accès à ses logs de séances. +// Résumé compact (X/55 débloqués + barre de progression) avec un bouton +// "Voir le détail" qui ouvre le catalogue complet dans une modale, groupé par +// catégorie — évite de dérouler 55 badges directement sur le profil. +// +// Couvre les deux catalogues fusionnés côté back (buildAchievementsView) : +// le V2 backend ({name, description}, ~14 trophées) et le miroir du V1 local +// ({label, condition, epicDesc, gradientColors}, ~41 trophées). // // Props : -// achievements Array<{ id, name, description, category, hidden, unlocked, unlockedAt }> +// achievements Array<{ id, name|label, description|condition, category, hidden, unlocked, unlockedAt }> // stats { total, unlocked, percentage } const ICON_BY_ID = { @@ -21,9 +25,14 @@ const ICON_BY_ID = { FIRST_RARE_ITEM: 'cube', FIRST_EPIC_ITEM: 'diamond', FIRST_LEGENDARY_ITEM: 'flame', - FIRST_UNIQUE_ITEM: 'star', + FIRST_UNIQUE_ITEM: 'dragon', FIRST_REFERRAL: 'person-add', FRIENDSHIP_LEVEL_5: 'heart', + CHEST_1: 'cube-outline', + CHEST_10: 'cube', + CHEST_50: 'gift', + CHEST_100: 'trophy', + CHEST_200: 'diamond', }; const RARITY_BY_ID = { @@ -34,59 +43,175 @@ const RARITY_BY_ID = { FIRST_UNIQUE_ITEM: 'unique', }; +// Palier de rareté (glow + intensité visuelle) — sans cette table, tous les +// trophées backend retombaient sur le tier par défaut ("bronze"), écrasant +// toute lecture de progression/difficulté dans la Salle des Trophées. +const TIER_BY_ID = { + BIRTHDAY_SET: 'bronze', + BIRTHDAY_CELEBRATED: 'gold', + FIRST_COMMON_ITEM: 'bronze', + FIRST_RARE_ITEM: 'silver', + FIRST_EPIC_ITEM: 'gold', + FIRST_LEGENDARY_ITEM: 'platinum', + FIRST_UNIQUE_ITEM: 'diamond', + FIRST_REFERRAL: 'silver', + FRIENDSHIP_LEVEL_5: 'diamond', + CHEST_1: 'bronze', + CHEST_10: 'silver', + CHEST_50: 'gold', + CHEST_100: 'platinum', + CHEST_200: 'diamond', +}; + +// FRIENDSHIP_LEVEL_5 ("Lien de Sang") est la même récompense Rareté Unique +// que le cadre cosmétique — même identité Rouge Sang, pas la couleur sociale +// générique. +const COLOR_OVERRIDE_BY_ID = { + FRIENDSHIP_LEVEL_5: Colors.uniqueBlood, +}; + const CATEGORY_COLOR = { - profile: Colors.rankViolet, - collection: Colors.primary, - social: '#22D3EE', + profile: Colors.rankViolet, + collection: Colors.primary, + social: '#22D3EE', + heritage: '#FE7439', + force: '#DC2626', + exploration: '#22C55E', + secret: '#6366F1', + corps: '#D1D5DB', + regularite: '#10B981', + special: '#FE7439', + ultime: '#FFD700', +}; + +const CATEGORY_LABELS = { + profile: 'Profil', + collection: 'Collection', + social: 'Social', + heritage: 'Héritage', + force: 'Force', + exploration: 'Exploration', + secret: 'Secrets', + corps: 'Poids du corps', + regularite: 'Régularité', + special: 'Spécial', + ultime: 'Ultime', }; +const CATEGORY_ORDER = [ + 'heritage', 'force', 'exploration', 'corps', 'regularite', + 'collection', 'profile', 'social', 'special', 'secret', 'ultime', +]; + +// Hauteur numérique fixe (et non un pourcentage) : sur react-native-web, un +// enfant `flex: 1` dans un parent contraint seulement par `maxHeight` (sans +// `height`) calcule mal sa zone scrollable — le scroll ne répondait qu'au +// centre de la modale, jamais en haut ni en bas. +const SHEET_MAX_HEIGHT = Math.round(Dimensions.get('window').height * 0.82); + function colorFor(achievement) { + // Les trophées du catalogue local synchronisé portent leur couleur ; + // les trophées backend historiques passent par les maps de repli. + if (COLOR_OVERRIDE_BY_ID[achievement.id]) return COLOR_OVERRIDE_BY_ID[achievement.id]; + if (achievement.color) return achievement.color; const rarity = RARITY_BY_ID[achievement.id]; if (rarity) return RARITY_META[rarity].color; return CATEGORY_COLOR[achievement.category] || Colors.primary; } +// Normalise les deux formes de catalogue (backend V2 vs miroir local) vers un +// seul shape exploitable par TrophySlot/FeaturedModal (mêmes composants que +// la Vitrine du profil). +export function normalizeAchievement(a) { + const color = colorFor(a); + const hiddenLocked = a.hidden && !a.unlocked; + return { + ...a, + label: a.label || a.name || '', + condition: hiddenLocked ? '???' : (a.condition || a.description || ''), + epicDesc: hiddenLocked ? 'Ce trophée est encore secret.' : (a.epicDesc || a.description || a.condition || ''), + color, + gradientColors: a.gradientColors || [color, color, color], + icon: hiddenLocked ? 'help-circle' : (a.icon || ICON_BY_ID[a.id] || 'trophy'), + tier: a.tier || TIER_BY_ID[a.id] || 'bronze', + }; +} + export default function AchievementShowcase({ achievements = [], stats }) { + const [detailOpen, setDetailOpen] = useState(false); + const [selected, setSelected] = useState(null); + + const normalized = achievements.map(normalizeAchievement); + const byCategory = CATEGORY_ORDER + .map((cat) => ({ cat, items: normalized.filter((a) => a.category === cat) })) + .filter((g) => g.items.length > 0); + return ( {stats && ( - - {stats.unlocked}/{stats.total} trophées débloqués - - - + + + {stats.unlocked}/{stats.total} trophées débloqués + + + + {stats.percentage}% )} - - {achievements.map((a, i) => ( - - ))} - + setDetailOpen(true)} activeOpacity={0.8}> + + Voir le détail + + + + {detailOpen && ( + setDetailOpen(false)}> + setDetailOpen(false)}> + {}}> + + Trophées {stats ? `· ${stats.unlocked}/${stats.total}` : ''} + setDetailOpen(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + + + + + {byCategory.map(({ cat, items }) => ( + + {CATEGORY_LABELS[cat] || cat} + + {items.map((a) => ( + setSelected(a)} /> + ))} + + + ))} + + + + + + )} + + {selected && ( + setSelected(null)} /> + )} ); } -function AchievementBadge({ achievement, color, index }) { - const scale = useRef(new Animated.Value(0.85)).current; - const opacity = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.parallel([ - Animated.spring(scale, { toValue: 1, friction: 7, tension: 60, delay: index * 40, useNativeDriver: true }), - Animated.timing(opacity, { toValue: 1, duration: 260, delay: index * 40, useNativeDriver: true }), - ]).start(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const { unlocked, hidden } = achievement; - const icon = hidden && !unlocked ? 'help-circle' : (ICON_BY_ID[achievement.id] || 'trophy'); - +function AchievementBadge({ achievement, onPress }) { + const { unlocked, color, icon, label } = achievement; return ( - + - + - {achievement.name} + {label} {!unlocked && ( )} - + ); } const styles = StyleSheet.create({ statsBar: { flexDirection: 'row', alignItems: 'center', gap: 10, - marginBottom: 16, + marginBottom: 12, }, - statsTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600' }, + statsTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600', marginBottom: 6 }, statsBarTrack: { - flex: 1, height: 6, borderRadius: 3, + height: 6, borderRadius: 3, backgroundColor: 'rgba(255,255,255,0.08)', overflow: 'hidden', }, statsBarFill: { height: '100%', backgroundColor: Colors.gold, borderRadius: 3 }, - statsPct: { color: Colors.gold, fontSize: 12, fontWeight: '800' }, + statsPct: { color: Colors.gold, fontSize: 14, fontWeight: '800' }, + + detailBtn: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, + borderWidth: 1, borderColor: 'rgba(255,215,0,0.3)', backgroundColor: 'rgba(255,215,0,0.06)', + borderRadius: 12, paddingVertical: 11, + }, + detailBtnText: { color: Colors.gold, fontSize: 13, fontWeight: '700' }, + + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.74)', justifyContent: 'flex-end' }, + sheet: { + height: SHEET_MAX_HEIGHT, backgroundColor: Colors.bgAbyss, + borderTopLeftRadius: 26, borderTopRightRadius: 26, + borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', + paddingHorizontal: 18, paddingTop: 18, paddingBottom: 10, + }, + sheetHeader: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + marginBottom: 16, + }, + sheetTitle: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' }, + scrollArea: { flex: 1 }, + categoryLabel: { + color: Colors.textMuted, fontSize: 11, fontWeight: '800', + letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10, + }, grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, diff --git a/front/src/components/profile/AvatarFrame.js b/front/src/components/profile/AvatarFrame.js index 53e57ec..0b605ed 100644 --- a/front/src/components/profile/AvatarFrame.js +++ b/front/src/components/profile/AvatarFrame.js @@ -28,6 +28,13 @@ export const COLOR_DEFS = [ { id: 'grandmaster', name: 'Grand Maître', unlockLevel: 141, colors: ['#5B21B6','#9333EA','#C084FC','#F0ABFC','#FFFFFF','#F0ABFC','#C084FC','#9333EA'], animated: true, glowColor: 'rgba(240,171,252,0.85)', borderWidth: 5 }, { id: 'legend', name: 'Légende', unlockLevel: 171, colors: ['#4C1D95','#7C3AED','#A855F7','#C084FC','#F5D0FE','#FFFFFF','#F5D0FE','#C084FC'], animated: true, glowColor: 'rgba(245,208,254,0.90)', borderWidth: 5.5 }, { id: 'god', name: 'ATHLY GOD', unlockLevel: 200, colors: ['#78350F','#B45309','#D97706','#F59E0B','#FDE68A','#FFD700','#FDE68A','#F59E0B','#D97706'], animated: true, glowColor: 'rgba(255,215,0,0.95)', borderWidth: 6 }, + // Cosmétique Unique — débloquée en réclamant l'item FRAME_COLOR_BLOOD_SANG + // (streak de groupe 30j à 5 membres), jamais par le niveau : unlockLevel + // n'est là que pour l'affichage, `special` court-circuite le calcul de + // verrouillage (voir isColorLocked dans BorderPicker.js). + { id: 'bloodsang', name: 'Rouge Sang', unlockLevel: Infinity, special: true, specialFlag: 'FRAME_COLOR_BLOODSANG', + colors: ['#3D0000','#7A0000','#A30000','#FF2E4D','#A30000','#7A0000','#3D0000'], + animated: true, glowColor: 'rgba(163,0,0,0.80)', borderWidth: 5 }, ]; export const SHAPE_DEFS = [ @@ -40,6 +47,9 @@ export const SHAPE_DEFS = [ { id: 'crown', name: 'Couronne', unlockLevel: 141, extraPad: 6, extraTop: 30 }, { id: 'wings', name: 'Ailes', unlockLevel: 171, extraPad: 28, extraTop: 0 }, { id: 'divine', name: 'Divin', unlockLevel: 200, extraPad: 16, extraTop: 16 }, + // Cosmétique Unique — débloquée en réclamant l'item PROFILE_FRAME_BLOOD_BOND + // (niveau d'amitié 5), jamais par le niveau (voir isShapeLocked). + { id: 'dragonfang', name: 'Croc de Dragon', unlockLevel: Infinity, special: true, specialFlag: 'FRAME_SHAPE_DRAGONFANG', extraPad: 34, extraTop: 0 }, ]; export const FRAME_DEFS = COLOR_DEFS.map((c) => ({ @@ -52,6 +62,21 @@ export function getColorDef(id) { return COLOR_DEFS.find((c) => c.id === id) || export function getShapeDef(id) { return SHAPE_DEFS.find((s) => s.id === id) || SHAPE_DEFS[0]; } export function getFrameDef(id) { return FRAME_DEFS.find((f) => f.id === id) || FRAME_DEFS[0]; } +// Empreinte visuelle réelle (largeur/hauteur) d'un cadre à une taille donnée — +// même formule que le calcul interne d'AvatarFrame. Permet aux écrans qui +// affichent le cadre dans un espace réservé fixe (carte de joueur…) de le +// réduire proportionnellement (transform: scale) plutôt que de le laisser +// déborder pour les formes les plus ornées (Ailes, Divin, Croc de Dragon…). +export function getFrameFootprint(shapeId, colorId, size) { + const color = getColorDef(colorId); + const shape = getShapeDef(shapeId); + if (!color.colors) return { totalW: size, totalH: size }; + const bw = color.borderWidth; + const totalW = size + bw * 2 + shape.extraPad * 2; + const totalH = totalW + shape.extraTop; + return { totalW, totalH }; +} + // ─── SVG path helpers ───────────────────────────────────────────────────────── function polyPath(cx, cy, r, sides, rotDeg = 0) { @@ -87,6 +112,63 @@ function spikePath(cx, cy, hexR, spikeR) { return 'M' + pts.join(' L') + ' Z'; } +function cubicPoint(p0, p1, p2, p3, t) { + const mt = 1 - t; + return { + x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x, + y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y, + }; +} + +// Corne draconique/démoniaque : base épaisse (cachée derrière le bouclier), +// balayage large vers l'extérieur puis crochet de pointe qui se recourbe — +// silhouette de corne de bélier/démon, pas une simple dague fine. La largeur +// décroît le long de la courbe (base épaisse → pointe acérée) et l'épaisseur +// est asymétrique (bord extérieur convexe, bord intérieur plus plat) pour un +// vrai volume de corne plutôt qu'une lame plate. +function hornPath(base, ctrl1, ctrl2, tip, baseWidth, steps = 16) { + const pts = Array.from({ length: steps + 1 }, (_, i) => { + const t = i / steps; + return { ...cubicPoint(base, ctrl1, ctrl2, tip, t), t }; + }); + + const outer = []; + const inner = []; + for (let i = 0; i < pts.length; i++) { + const p = pts[i]; + const prev = pts[Math.max(0, i - 1)]; + const next = pts[Math.min(pts.length - 1, i + 1)]; + const dx = next.x - prev.x, dy = next.y - prev.y; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len, ny = dx / len; + // Effilement non-linéaire (racine carrée) : la corne reste large plus + // longtemps avant de s'affiner brutalement vers une pointe acérée. + const w = baseWidth * Math.pow(1 - p.t, 0.55) + baseWidth * 0.045; + outer.push({ x: p.x + nx * w, y: p.y + ny * w }); + inner.push({ x: p.x - nx * w * 0.5, y: p.y - ny * w * 0.5 }); + } + + let d = `M${outer[0].x.toFixed(1)},${outer[0].y.toFixed(1)} `; + for (let i = 1; i < outer.length; i++) d += `L${outer[i].x.toFixed(1)},${outer[i].y.toFixed(1)} `; + for (let i = inner.length - 1; i >= 0; i--) d += `L${inner[i].x.toFixed(1)},${inner[i].y.toFixed(1)} `; + d += 'Z'; + return d; +} + +// Anneaux de striation le long d'une corne (détail façon corne de bélier) — +// petits traits perpendiculaires à la courbe à intervalles réguliers. +function hornRidges(base, ctrl1, ctrl2, tip, baseWidth, positions = [0.28, 0.48, 0.66]) { + return positions.map((t) => { + const p = cubicPoint(base, ctrl1, ctrl2, tip, t); + const p2 = cubicPoint(base, ctrl1, ctrl2, tip, Math.min(1, t + 0.02)); + const dx = p2.x - p.x, dy = p2.y - p.y; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len, ny = dx / len; + const w = (baseWidth * Math.pow(1 - t, 0.55) + baseWidth * 0.045) * 0.92; + return `M${(p.x + nx * w).toFixed(1)},${(p.y + ny * w).toFixed(1)} L${(p.x - nx * w * 0.5).toFixed(1)},${(p.y - ny * w * 0.5).toFixed(1)}`; + }).join(' '); +} + function chamfPath(cx, cy, hw, hh, cut) { return [ `M${(cx - hw + cut).toFixed(1)},${(cy - hh).toFixed(1)}`, @@ -529,6 +611,41 @@ export default function AvatarFrame({ shapeId = 'circle', colorId = 'none', size break; } + // ── Croc de Dragon — bouclier + cornes démoniaques recourbées derrière ─── + case 'dragonfang': { + outerD = shieldPath(cx, cy, outerR); + innerD = shieldPath(cx, cy, innerR); + + const hw = outerR * 0.30; // base large et agressive + + const leftBase = { x: cx + outerR * 0.16, y: cy + outerR * 0.34 }; + const leftCtrl1 = { x: cx - outerR * 0.55, y: cy - outerR * 0.15 }; + const leftCtrl2 = { x: cx - outerR * 1.55, y: cy - outerR * 0.65 }; + const leftTip = { x: cx - outerR * 1.05, y: cy - outerR * 1.55 }; + + const rightBase = { x: cx - outerR * 0.16, y: cy + outerR * 0.34 }; + const rightCtrl1 = { x: cx + outerR * 0.55, y: cy - outerR * 0.15 }; + const rightCtrl2 = { x: cx + outerR * 1.55, y: cy - outerR * 0.65 }; + const rightTip = { x: cx + outerR * 1.05, y: cy - outerR * 1.55 }; + + // Deux cornes en crochet, croisées en X : bases cachées derrière le + // bouclier, pointes recourbées dépassant des épaules haut-gauche/droite. + const hornLeft = hornPath(leftBase, leftCtrl1, leftCtrl2, leftTip, hw); + const hornRight = hornPath(rightBase, rightCtrl1, rightCtrl2, rightTip, hw); + extra1D = hornLeft + ' ' + hornRight; + extra1Fill = midColor; extra1Op = 0.97; + extra1Stroke = topColor; extra1StrokeW = 0.9; + + // Striations façon corne de bélier + œil de dragon (gemme centrale). + const ridgesLeft = hornRidges(leftBase, leftCtrl1, leftCtrl2, leftTip, hw); + const ridgesRight = hornRidges(rightBase, rightCtrl1, rightCtrl2, rightTip, hw); + const gem = diamondAt(cx, cy + innerR * 0.42, Math.max(2.4, outerR * 0.07)); + extra2D = gem + ' ' + ridgesLeft + ' ' + ridgesRight; + extra2Fill = topColor; extra2Op = 0.9; + extra2Stroke = topColor; extra2StrokeW = 0.8; + break; + } + default: outerD = polyPath(cx, cy, outerR, 6, 0); innerD = polyPath(cx, cy, innerR, 6, 0); @@ -741,6 +858,23 @@ export function ShapePreview({ shapeId, colorId = 'bronze', size = 38, locked = outerD = circlePath(cx, cy, outerR); innerD = circlePath(cx, cy, innerR); break; + case 'dragonfang': { + outerD = shieldPath(cx, cy, outerR); + innerD = shieldPath(cx, cy, innerR); + const hw = outerR * 0.30; + const lB = { x: cx + outerR * 0.16, y: cy + outerR * 0.34 }; + const lC1 = { x: cx - outerR * 0.55, y: cy - outerR * 0.15 }; + const lC2 = { x: cx - outerR * 1.55, y: cy - outerR * 0.65 }; + const lT = { x: cx - outerR * 1.05, y: cy - outerR * 1.55 }; + const rB = { x: cx - outerR * 0.16, y: cy + outerR * 0.34 }; + const rC1 = { x: cx + outerR * 0.55, y: cy - outerR * 0.15 }; + const rC2 = { x: cx + outerR * 1.55, y: cy - outerR * 0.65 }; + const rT = { x: cx + outerR * 1.05, y: cy - outerR * 1.55 }; + extra1D = hornPath(lB, lC1, lC2, lT, hw) + ' ' + hornPath(rB, rC1, rC2, rT, hw); + extra1Fill = midColor; extra1Op = 0.97; + extra1Stroke = topColor; extra1StrokeW = 0.7; + break; + } default: outerD = polyPath(cx, cy, outerR, 6, 0); innerD = polyPath(cx, cy, innerR, 6, 0); diff --git a/front/src/components/profile/BorderPicker.js b/front/src/components/profile/BorderPicker.js index fb39dde..58fd2cf 100644 --- a/front/src/components/profile/BorderPicker.js +++ b/front/src/components/profile/BorderPicker.js @@ -40,7 +40,9 @@ const ShapeItem = React.memo(function ShapeItem({ shape, previewColor, selected, {shape.name} - {shape.unlockLevel === 0 ? 'Dispo' : `Niv. ${shape.unlockLevel}`} + + {shape.special ? 'Unique' : (shape.unlockLevel === 0 ? 'Dispo' : `Niv. ${shape.unlockLevel}`)} + ); }); @@ -68,7 +70,9 @@ const ColorItem = React.memo(function ColorItem({ colorDef, selected, locked, on {colorDef.name} - {colorDef.unlockLevel === 0 ? 'Dispo' : `Niv. ${colorDef.unlockLevel}`} + + {colorDef.special ? 'Unique' : (colorDef.unlockLevel === 0 ? 'Dispo' : `Niv. ${colorDef.unlockLevel}`)} + ); }); @@ -81,6 +85,7 @@ export default function BorderPicker({ currentShapeId = 'circle', currentColorId = 'none', playerLevel = 0, + unlockedCosmetics = [], userInitial = 'A', onSelectShape, onSelectColor, @@ -105,8 +110,15 @@ export default function BorderPicker({ if (onSelectColor) onSelectColor(id); }, [onSelectColor]); - const unlockedShapes = SHAPE_DEFS.filter((s) => playerLevel >= s.unlockLevel).length; - const unlockedColors = COLOR_DEFS.filter((c) => playerLevel >= c.unlockLevel).length; + // Cosmétiques "special" (Croc de Dragon, Rouge Sang…) ne sont jamais + // gérés par le niveau : ils dépendent d'un flag réclamé côté backend + // (voir inventory.controller.js → claimUniqueItem). + const isLocked = useCallback((def) => ( + def.special ? !unlockedCosmetics.includes(def.specialFlag) : playerLevel < def.unlockLevel + ), [playerLevel, unlockedCosmetics]); + + const unlockedShapes = SHAPE_DEFS.filter((s) => !isLocked(s)).length; + const unlockedColors = COLOR_DEFS.filter((c) => !isLocked(c)).length; // Build flat FlatList data: no nested ScrollViews → no scroll-grip issues const flatData = useMemo(() => { @@ -128,7 +140,7 @@ export default function BorderPicker({ rows.push({ type: 'spacer' }); return rows; - }, [previewShape, previewColor, playerLevel, unlockedShapes, unlockedColors]); + }, [unlockedShapes, unlockedColors]); const renderItem = useCallback(({ item }) => { switch (item.type) { @@ -182,7 +194,7 @@ export default function BorderPicker({ shape={shape} previewColor={previewColor} selected={shape.id === previewShape} - locked={playerLevel < shape.unlockLevel} + locked={isLocked(shape)} onPress={handleShape} /> ))} @@ -201,7 +213,7 @@ export default function BorderPicker({ key={colorDef.id} colorDef={colorDef} selected={colorDef.id === previewColor} - locked={playerLevel < colorDef.unlockLevel} + locked={isLocked(colorDef)} onPress={handleColor} /> ))} @@ -217,7 +229,7 @@ export default function BorderPicker({ default: return null; } - }, [previewShape, previewColor, playerLevel, userInitial, handleShape, handleColor]); + }, [previewShape, previewColor, userInitial, handleShape, handleColor, isLocked]); return ( ({ ...normalizeAchievement(a), unlocked: true })); + + return ( + + + + VITRINE DE {(pseudo || '').toUpperCase()} + + + {trophies.map((trophy) => ( + setSelected(trophy)} /> + ))} + + + {selected && ( + setSelected(null)} /> + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: 'rgba(255,215,0,0.05)', + borderWidth: 1, borderColor: 'rgba(255,215,0,0.22)', + borderRadius: 16, padding: 14, marginTop: 14, + }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 12, + }, + title: { + color: Colors.gold, fontSize: 10.5, fontWeight: '800', letterSpacing: 1.2, + }, + row: { flexDirection: 'row', gap: 10 }, +}); diff --git a/front/src/components/profile/HeroLevelCard.js b/front/src/components/profile/HeroLevelCard.js index 146012a..f8e88de 100644 --- a/front/src/components/profile/HeroLevelCard.js +++ b/front/src/components/profile/HeroLevelCard.js @@ -3,7 +3,18 @@ import { View, Text, StyleSheet, Animated } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { xpToLevel, getRank } from '../../services/stats.service'; -import AvatarFrame from './AvatarFrame'; +import AvatarFrame, { getFrameFootprint } from './AvatarFrame'; + +const AVATAR_SIZE = 90; +// Empreinte max tolérée avant de réduire le cadre. Volontairement plus grande +// que l'espace réservé (avatarWrap, 108×108) : le contenu peut légèrement +// déborder de cette réserve sans jamais toucher le nom en dessous (marge +// avatarWrap.marginBottom), et la plupart des formes (jusqu'à Divin/Couronne) +// rentrent déjà sous ce seuil sans la moindre réduction. Seules les formes +// les plus extrêmes (Ailes, Croc de Dragon) sont légèrement réduites — +// beaucoup moins qu'avec un seuil calé strictement sur 108, qui les faisait +// paraître minuscules. +const MAX_FRAME_FOOTPRINT = 132; export default function HeroLevelCard({ name = 'Athlète', @@ -34,6 +45,14 @@ export default function HeroLevelCard({ const pct = Math.max(0, Math.min(1, progress || 0)); + // Réduit proportionnellement les cadres dont l'empreinte réelle dépasse + // l'espace réservé sur la carte, plutôt que de laisser déborder. + const frameScale = useMemo(() => { + const { totalW, totalH } = getFrameFootprint(shapeId, colorId, AVATAR_SIZE); + const maxDim = Math.max(totalW, totalH); + return maxDim > MAX_FRAME_FOOTPRINT ? MAX_FRAME_FOOTPRINT / maxDim : 1; + }, [shapeId, colorId]); + const hasGlow = profileTheme && profileTheme.id !== 'auto' ? profileTheme.hasGlow : level >= 71; const hasNeonRing = profileTheme && profileTheme.id !== 'auto' ? ['elite', 'legend', 'god'].includes(profileTheme.bgVariant) @@ -93,11 +112,13 @@ export default function HeroLevelCard({ The children View is kept as fallback for colorId='none' (no-frame case) where avatarStyle carries the neon-ring border and glow shadow. */} - - - {(initial || 'U').slice(0, 1).toUpperCase()} - - + + + + {(initial || 'U').slice(0, 1).toUpperCase()} + + + {level} @@ -188,7 +209,11 @@ const styles = StyleSheet.create({ height: 108, alignItems: 'center', justifyContent: 'center', - marginBottom: 14, + // Un peu plus que l'ancienne valeur (14) : les cadres les plus ornés + // peuvent légèrement dépasser de la réserve 108×108 (voir + // MAX_FRAME_FOOTPRINT) — cette marge absorbe ce débord sans jamais + // toucher le nom affiché juste en dessous. + marginBottom: 22, }, glowHalo: { position: 'absolute', diff --git a/front/src/components/profile/TrophyGrid.js b/front/src/components/profile/TrophyGrid.js index d9a788b..6918ae3 100644 --- a/front/src/components/profile/TrophyGrid.js +++ b/front/src/components/profile/TrophyGrid.js @@ -1,144 +1,7 @@ -import React, { useRef, useEffect, useState } from 'react'; -import { - View, Text, StyleSheet, Animated, - Modal, TouchableOpacity, Pressable, -} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Colors } from '../../constants/theme'; +import React, { useState } from 'react'; +import { View, StyleSheet } from 'react-native'; import { MAX_FEATURED } from '../../hooks/useFeaturedTrophies'; - -const GLOW = { - bronze: { opacity: 0.55, radius: 12, elevation: 8 }, - silver: { opacity: 0.78, radius: 20, elevation: 16 }, - gold: { opacity: 0.92, radius: 28, elevation: 22 }, - platinum: { opacity: 1.0, radius: 36, elevation: 28 }, - diamond: { opacity: 1.0, radius: 44, elevation: 36 }, - // legacy compat - low: { opacity: 0.55, radius: 12, elevation: 8 }, - medium: { opacity: 0.82, radius: 22, elevation: 18 }, - high: { opacity: 1.0, radius: 36, elevation: 28 }, -}; - -// ─── TrophySlot ─────────────────────────────────────────────────────────────── - -function TrophySlot({ trophy, onPress }) { - const { icon, label, condition, desc, color, gradientColors, unlocked, tier, glowIntensity } = trophy; - const gleamX = useRef(new Animated.Value(-56)).current; - - useEffect(() => { - if (!unlocked) return; - const loop = Animated.loop( - Animated.sequence([ - Animated.timing(gleamX, { toValue: -56, duration: 1, useNativeDriver: true }), - Animated.delay(2600), - Animated.timing(gleamX, { toValue: 76, duration: 520, useNativeDriver: true }), - Animated.delay(380), - ]), - ); - loop.start(); - return () => loop.stop(); - }, [unlocked, gleamX]); - - const glow = GLOW[tier] || GLOW[glowIntensity] || GLOW.bronze; - - return ( - - - {unlocked ? ( - - - - - ) : ( - - - - )} - - {label} - - {condition || desc || ''} - - - ); -} - -// ─── EmptySlot ──────────────────────────────────────────────────────────────── - -function EmptySlot({ onPress }) { - return ( - - - - - Choisir - Voir tout - - ); -} - -// ─── FeaturedModal : tap sur un trophée mis en avant ───────────────────────── - -function FeaturedModal({ trophy, onClose, onUnfeature }) { - const { icon, label, condition, desc, epicDesc, color, gradientColors, unlocked } = trophy; - const scale = useRef(new Animated.Value(0.88)).current; - const opacity = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.parallel([ - Animated.spring(scale, { toValue: 1, useNativeDriver: true, tension: 130, friction: 8 }), - Animated.timing(opacity, { toValue: 1, duration: 190, useNativeDriver: true }), - ]).start(); - }, [scale, opacity]); - - return ( - - - - {}}> - - - - - - - {label} - {condition || desc || ''} - - - - Débloqué · En vitrine - - - - - {epicDesc || ''} - - - - Retirer de la vitrine - - - - Fermer - - - - - - ); -} - -// ─── TrophyGrid ─────────────────────────────────────────────────────────────── +import { TrophySlot, EmptySlot, FeaturedModal } from './TrophySlot'; // featuredIds + toggleFeatured proviennent du parent (ProfileScreen via useFeaturedTrophies) // → évite un état stale quand l'utilisateur revient de TrophyRoomScreen @@ -178,55 +41,6 @@ export default function TrophyGrid({ evaluatedCatalog = [], featuredIds = [], to ); } -// ─── Styles ─────────────────────────────────────────────────────────────────── - const styles = StyleSheet.create({ row: { flexDirection: 'row', gap: 10 }, - - slot: { - flex: 1, backgroundColor: 'rgba(22,22,31,0.97)', borderRadius: 16, - paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', - borderWidth: 0.5, borderColor: 'rgba(255,255,255,0.06)', - }, - iconWrap: { width: 56, height: 56, borderRadius: 18, marginBottom: 10 }, - iconGradient: { - width: 56, height: 56, borderRadius: 18, alignItems: 'center', justifyContent: 'center', - overflow: 'hidden', borderTopWidth: 0.8, borderTopColor: 'rgba(255,255,255,0.48)', - borderWidth: 0.8, borderColor: 'rgba(255,255,255,0.08)', - }, - gleam: { position: 'absolute', top: -8, width: 16, height: 80, backgroundColor: 'rgba(255,255,255,0.30)', borderRadius: 6 }, - iconLocked: { width: 56, height: 56, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.05)', alignItems: 'center', justifyContent: 'center', borderWidth: 0.8, borderColor: 'rgba(255,255,255,0.07)' }, - label: { fontSize: 12, fontWeight: '800', textAlign: 'center', letterSpacing: 0.3 }, - desc: { fontSize: 10, fontWeight: '600', textAlign: 'center', marginTop: 3, letterSpacing: 0.2 }, - - emptySlot: { - flex: 1, borderRadius: 16, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', - borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)', borderStyle: 'dashed', - backgroundColor: 'rgba(255,255,255,0.02)', - }, - emptyIcon: { - width: 56, height: 56, borderRadius: 18, marginBottom: 10, - alignItems: 'center', justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.04)', - borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', - }, - emptyLabel: { fontSize: 12, fontWeight: '700', color: 'rgba(255,255,255,0.22)', letterSpacing: 0.3 }, - emptyHint: { fontSize: 10, fontWeight: '500', color: 'rgba(255,255,255,0.14)', marginTop: 3 }, - - overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.74)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }, - modalCard: { width: '100%', backgroundColor: 'rgba(20,20,28,0.94)', borderRadius: 28, borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', borderTopColor: 'rgba(255,255,255,0.22)', shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.70, shadowRadius: 48, elevation: 32 }, - modalInner: { paddingVertical: 32, paddingHorizontal: 24, alignItems: 'center' }, - modalIconShadow: { marginBottom: 20, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.90, shadowRadius: 32, elevation: 24 }, - modalIconGradient: { width: 96, height: 96, borderRadius: 30, alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.55)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.10)' }, - modalLabel: { fontSize: 26, fontWeight: '900', letterSpacing: 0.4, marginBottom: 5 }, - modalCondition:{ color: Colors.textMuted, fontSize: 13, fontWeight: '600', letterSpacing: 0.4, marginBottom: 14 }, - badge: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 14, paddingVertical: 7, borderRadius: 20, borderWidth: 1, marginBottom: 4 }, - badgeText: { fontSize: 12, fontWeight: '700', letterSpacing: 0.5 }, - modalDivider: { width: '55%', height: 1, marginVertical: 20 }, - modalEpicDesc: { color: Colors.textSecondary, fontSize: 14, fontWeight: '500', textAlign: 'center', lineHeight: 22, marginBottom: 20, paddingHorizontal: 4 }, - - unfeaturedBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 12, paddingHorizontal: 28, borderRadius: 14, borderWidth: 1, marginBottom: 10 }, - unfeaturedBtnText: { fontSize: 14, fontWeight: '700' }, - closeBtn: { paddingVertical: 10, paddingHorizontal: 40, borderRadius: 14, borderWidth: 1 }, - closeTxt: { fontSize: 14, fontWeight: '700', color: Colors.textMuted, letterSpacing: 0.4 }, }); diff --git a/front/src/components/profile/TrophySlot.js b/front/src/components/profile/TrophySlot.js new file mode 100644 index 0000000..2f1b0a3 --- /dev/null +++ b/front/src/components/profile/TrophySlot.js @@ -0,0 +1,211 @@ +import React, { useRef, useEffect } from 'react'; +import { + View, Text, StyleSheet, Animated, + Modal, TouchableOpacity, Pressable, +} from 'react-native'; +import { Ionicons, FontAwesome5 } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Colors } from '../../constants/theme'; + +// Certains trophées utilisent une glyphe hors du set Ionicons (ex: "dragon" +// pour ATHLY UNIQUE — Ionicons n'a pas d'équivalent cornes/dragon correct). +// TrophyIcon route vers le bon set en un seul endroit, partagé par tous les +// rendus de trophée (vitrine, salle des trophées, badges). +const CUSTOM_ICON_SETS = { dragon: FontAwesome5 }; + +export function TrophyIcon({ name, size, color }) { + const CustomSet = CUSTOM_ICON_SETS[name]; + if (CustomSet) return ; + return ; +} + +// Rendu partagé entre la Vitrine du profil (ProfileScreen/TrophyGrid) et la +// vitrine d'un ami (FriendProfileScreen) — un seul composant, un seul style. + +export const GLOW = { + bronze: { opacity: 0.55, radius: 12, elevation: 8 }, + silver: { opacity: 0.78, radius: 20, elevation: 16 }, + gold: { opacity: 0.92, radius: 28, elevation: 22 }, + platinum: { opacity: 1.0, radius: 36, elevation: 28 }, + diamond: { opacity: 1.0, radius: 44, elevation: 36 }, + // legacy compat + low: { opacity: 0.55, radius: 12, elevation: 8 }, + medium: { opacity: 0.82, radius: 22, elevation: 18 }, + high: { opacity: 1.0, radius: 36, elevation: 28 }, +}; + +// ─── TrophySlot ─────────────────────────────────────────────────────────────── + +export function TrophySlot({ trophy, onPress }) { + const { icon, label, condition, desc, color, gradientColors, unlocked, tier, glowIntensity } = trophy; + const gleamX = useRef(new Animated.Value(-56)).current; + + useEffect(() => { + if (!unlocked) return; + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(gleamX, { toValue: -56, duration: 1, useNativeDriver: true }), + Animated.delay(2600), + Animated.timing(gleamX, { toValue: 76, duration: 520, useNativeDriver: true }), + Animated.delay(380), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [unlocked, gleamX]); + + const glow = GLOW[tier] || GLOW[glowIntensity] || GLOW.bronze; + + return ( + + + {unlocked ? ( + + + + + ) : ( + + + + )} + + {label} + + {condition || desc || ''} + + + ); +} + +// ─── EmptySlot ──────────────────────────────────────────────────────────────── + +export function EmptySlot({ onPress, label = 'Choisir', hint = 'Voir tout' }) { + return ( + + + + + {label} + {hint} + + ); +} + +// ─── FeaturedModal : tap sur un trophée mis en avant ───────────────────────── +// onUnfeature optionnel : absent → lecture seule (vitrine d'un ami) + +export function FeaturedModal({ trophy, onClose, onUnfeature }) { + const { icon, label, condition, desc, epicDesc, color, gradientColors, unlocked } = trophy; + const scale = useRef(new Animated.Value(0.88)).current; + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.spring(scale, { toValue: 1, useNativeDriver: true, tension: 130, friction: 8 }), + Animated.timing(opacity, { toValue: 1, duration: 190, useNativeDriver: true }), + ]).start(); + }, [scale, opacity]); + + return ( + + + + {}}> + + + + + + + {label} + {condition || desc || ''} + + {unlocked && ( + + + {onUnfeature ? 'Débloqué · En vitrine' : 'Débloqué'} + + )} + + + + {epicDesc || ''} + + {onUnfeature && ( + + + Retirer de la vitrine + + )} + + + Fermer + + + + + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +export const styles = StyleSheet.create({ + row: { flexDirection: 'row', gap: 10 }, + + slot: { + flex: 1, backgroundColor: 'rgba(22,22,31,0.97)', borderRadius: 16, + paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', + borderWidth: 0.5, borderColor: 'rgba(255,255,255,0.06)', + }, + iconWrap: { width: 56, height: 56, borderRadius: 18, marginBottom: 10 }, + iconGradient: { + width: 56, height: 56, borderRadius: 18, alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', borderTopWidth: 0.8, borderTopColor: 'rgba(255,255,255,0.48)', + borderWidth: 0.8, borderColor: 'rgba(255,255,255,0.08)', + }, + gleam: { position: 'absolute', top: -8, width: 16, height: 80, backgroundColor: 'rgba(255,255,255,0.30)', borderRadius: 6 }, + iconLocked: { width: 56, height: 56, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.05)', alignItems: 'center', justifyContent: 'center', borderWidth: 0.8, borderColor: 'rgba(255,255,255,0.07)' }, + label: { fontSize: 12, fontWeight: '800', textAlign: 'center', letterSpacing: 0.3 }, + desc: { fontSize: 10, fontWeight: '600', textAlign: 'center', marginTop: 3, letterSpacing: 0.2 }, + + emptySlot: { + flex: 1, borderRadius: 16, paddingVertical: 16, paddingHorizontal: 8, alignItems: 'center', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)', borderStyle: 'dashed', + backgroundColor: 'rgba(255,255,255,0.02)', + }, + emptyIcon: { + width: 56, height: 56, borderRadius: 18, marginBottom: 10, + alignItems: 'center', justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', + }, + emptyLabel: { fontSize: 12, fontWeight: '700', color: 'rgba(255,255,255,0.22)', letterSpacing: 0.3 }, + emptyHint: { fontSize: 10, fontWeight: '500', color: 'rgba(255,255,255,0.14)', marginTop: 3 }, + + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.74)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }, + modalCard: { width: '100%', backgroundColor: 'rgba(20,20,28,0.94)', borderRadius: 28, borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', borderTopColor: 'rgba(255,255,255,0.22)', shadowColor: '#000', shadowOffset: { width: 0, height: 24 }, shadowOpacity: 0.70, shadowRadius: 48, elevation: 32 }, + modalInner: { paddingVertical: 32, paddingHorizontal: 24, alignItems: 'center' }, + modalIconShadow: { marginBottom: 20, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.90, shadowRadius: 32, elevation: 24 }, + modalIconGradient: { width: 96, height: 96, borderRadius: 30, alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.55)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.10)' }, + modalLabel: { fontSize: 26, fontWeight: '900', letterSpacing: 0.4, marginBottom: 5 }, + modalCondition:{ color: Colors.textMuted, fontSize: 13, fontWeight: '600', letterSpacing: 0.4, marginBottom: 14 }, + badge: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 14, paddingVertical: 7, borderRadius: 20, borderWidth: 1, marginBottom: 4 }, + badgeText: { fontSize: 12, fontWeight: '700', letterSpacing: 0.5 }, + modalDivider: { width: '55%', height: 1, marginVertical: 20 }, + modalEpicDesc: { color: Colors.textSecondary, fontSize: 14, fontWeight: '500', textAlign: 'center', lineHeight: 22, marginBottom: 20, paddingHorizontal: 4 }, + + unfeaturedBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 12, paddingHorizontal: 28, borderRadius: 14, borderWidth: 1, marginBottom: 10 }, + unfeaturedBtnText: { fontSize: 14, fontWeight: '700' }, + closeBtn: { paddingVertical: 10, paddingHorizontal: 40, borderRadius: 14, borderWidth: 1 }, + closeTxt: { fontSize: 14, fontWeight: '700', color: Colors.textMuted, letterSpacing: 0.4 }, +}); diff --git a/front/src/components/social/FriendshipLevelUpModal.js b/front/src/components/social/FriendshipLevelUpModal.js new file mode 100644 index 0000000..64bc956 --- /dev/null +++ b/front/src/components/social/FriendshipLevelUpModal.js @@ -0,0 +1,147 @@ +import React, { useRef, useEffect } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import BirthdayConfetti from '../profile/BirthdayConfetti'; + +// Progression de couleur vers le Rouge Sang du niveau 5 ("Lien de Sang") — +// même esprit que RANK_COLORS dans LevelUpModal.js, mais indexé par niveau +// d'amitié (1 à 5) plutôt que par rang de niveau de joueur. +const FRIENDSHIP_LEVEL_COLORS = { + 1: '#9AA0AE', + 2: '#FF8FA3', + 3: '#FF4D6D', + 4: '#E11D48', + 5: Colors.uniqueBlood, +}; + +// ─── FriendshipLevelUpModal ─────────────────────────────────────────────────── +// Célébration dédiée à la montée de niveau d'amitié — miroir visuel de +// LevelUpModal.js (badge, carte, confettis) mais avec les couleurs et le +// message du lien d'amitié. Niveau 5 = message spécial (récompense Unique). +// +// Props : +// visible bool +// pseudo string — nom de l'ami concerné +// level number — nouveau niveau d'amitié (1–5) +// onClose () => void + +export default function FriendshipLevelUpModal({ visible, pseudo, level, onClose }) { + const color = FRIENDSHIP_LEVEL_COLORS[level] || FRIENDSHIP_LEVEL_COLORS[1]; + const isMax = level >= 5; + const badgeScale = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!visible) return; + badgeScale.setValue(0); + Animated.spring(badgeScale, { toValue: 1, friction: 5, tension: 60, useNativeDriver: true }).start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + return ( + + + + + + + + + + NIVEAU D'AMITIÉ + Niveau {level}/5 + avec {pseudo} + + + {isMax + ? "Niveau d'amitié maximum atteint ! Une récompense UNIQUE t'attend dans ton inventaire 🎁" + : 'Continuez à vous entraîner ensemble pour renforcer ce lien.'} + + + + Continuer + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + padding: 28, + alignItems: 'center', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.45, + shadowRadius: 32, + elevation: 20, + }, + badge: { + width: 76, + height: 76, + borderRadius: 24, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + eyebrow: { + color: Colors.textMuted, + fontSize: 11, + fontWeight: '800', + letterSpacing: 2, + marginBottom: 4, + }, + level: { + fontSize: 30, + fontWeight: '800', + letterSpacing: -0.5, + }, + rank: { + color: Colors.textSecondary, + fontSize: 14, + fontWeight: '700', + marginTop: 4, + marginBottom: 16, + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 22, + }, + closeBtn: { + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + closeBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/constants/theme.js b/front/src/constants/theme.js index ec6b3bf..2bb4801 100644 --- a/front/src/constants/theme.js +++ b/front/src/constants/theme.js @@ -38,6 +38,18 @@ export const Colors = { rankViolet: '#8B5CF6', // rang Elite warningAmber: '#F59E0B', // états d'avertissement (auth) + // ── Rareté UNIQUE — Rouge Sang premium ───────────────────────────────────── + // Identité visuelle dédiée aux cosmétiques/trophées de rareté Unique + // (cadre "Lien de Sang", couleur "Rouge Sang", thème de profil…). + // uniqueBlood : couleur de base (bordures, texte, icônes) + // uniqueBloodBright: reflet clair (dégradés, sheen animé) + // uniqueBloodDeep : ombre profonde (bas de dégradé, fonds teintés) + // uniqueBloodGlow : halo/lueur (shadowColor, glow pulsé) + uniqueBlood: '#A30000', + uniqueBloodBright: '#FF2E4D', + uniqueBloodDeep: '#3D0000', + uniqueBloodGlow: 'rgba(163,0,0,0.65)', + // ── Feedback ─────────────────────────────────────────────────────────────── valid: '#22C55E', // sets / exercices validés (vert chaud) success: '#44FF88', // XP / gains (vert néon) diff --git a/front/src/data/backendTrophyCategories.js b/front/src/data/backendTrophyCategories.js new file mode 100644 index 0000000..a871f79 --- /dev/null +++ b/front/src/data/backendTrophyCategories.js @@ -0,0 +1,10 @@ +// Catégorie ajoutée pour les trophées backend V2 sans équivalent local direct +// (collection d'objets par rareté + paliers de coffres ouverts). Partagé +// entre la Salle des Trophées et le panneau God Mode (Réglages) pour que les +// deux vues du catalogue combiné restent cohérentes. +export const COLLECTION_CATEGORY = { id: 'collection', label: 'Collection', icon: 'cube', color: '#3B82F6' }; + +// Les trophées "profil"/"social" du backend (anniversaire, parrainage, lien +// d'amitié niveau 5) rejoignent les catégories locales équivalentes ; la +// collection (rareté d'objets + coffres) forme sa propre catégorie. +export const BACKEND_CATEGORY_MAP = { profile: 'special', social: 'social', collection: 'collection' }; diff --git a/front/src/data/profileThemes.js b/front/src/data/profileThemes.js index 79dcd72..4b1a492 100644 --- a/front/src/data/profileThemes.js +++ b/front/src/data/profileThemes.js @@ -124,12 +124,31 @@ export const PROFILE_THEMES = [ hasGlow: true, bgVariant: 'god', }, + { + // Cosmétique Unique — débloqué en réclamant l'item THEME_UNLOCK_BLOOD_SANG + // (100 coffres ouverts), jamais par le niveau : unlockLevel n'est là que + // pour rester cohérent avec le tri, `requiresCosmetic` prime toujours + // (voir isThemeLocked et SettingsScreen.js). + id: 'blood_sang', + name: 'Rouge Sang', + description: 'Cramoisi + lueur sang · 100 coffres', + unlockLevel: 0, + requiresCosmetic: 'THEME_BLOODSANG', + requiresChests: 100, // pour le texte de progression avant réclamation + accentColor: '#FF2E4D', + glowColor: 'rgba(163,0,0,0.75)', + shimmer: true, + hasGlow: true, + bgVariant: 'blood', + }, ]; export function getTheme(id) { return PROFILE_THEMES.find((t) => t.id === id) || PROFILE_THEMES[0]; } -export function isThemeLocked(theme, playerLevel) { +// unlockedCosmetics : tableau des flags backend (user.unlockedCosmetics). +export function isThemeLocked(theme, playerLevel, unlockedCosmetics = []) { + if (theme.requiresCosmetic) return !unlockedCosmetics.includes(theme.requiresCosmetic); return playerLevel < theme.unlockLevel; } diff --git a/front/src/hooks/useFeaturedTrophies.js b/front/src/hooks/useFeaturedTrophies.js index 03c9d90..b62e8f9 100644 --- a/front/src/hooks/useFeaturedTrophies.js +++ b/front/src/hooks/useFeaturedTrophies.js @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { updateShowcase } from '../services/profile.service'; const FEATURED_KEY = 'athly:pref:featuredTrophies:v1'; export const MAX_FEATURED = 3; @@ -28,6 +29,9 @@ export function useFeaturedTrophies() { next = [...prev.slice(1), trophyId]; } AsyncStorage.setItem(FEATURED_KEY, JSON.stringify(next)).catch(() => {}); + // Fire-and-forget : la vitrine locale (soi-même) ne doit jamais dépendre + // du réseau. Le backend ne sert qu'à exposer la vitrine aux amis. + updateShowcase(next).catch(() => {}); return next; }); }, []); diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index a9a24f4..c8fdfb3 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -9,13 +9,24 @@ import { Colors } from '../../constants/theme'; import { useUser } from '../../context/UserContext'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import { useToast } from '../../context/ToastContext'; -import { openChest, useItem, ITEM_CATALOG, RARITY_META } from '../../services/inventory.service'; +import { openChest, useItem, claimUniqueItem, ITEM_CATALOG, RARITY_META } from '../../services/inventory.service'; import { xpForLevel, xpToLevel } from '../../services/stats.service'; +import { useAvatarFrame } from '../../hooks/useAvatarFrame'; +import { useDevSettings } from '../../hooks/useDevSettings'; import ChestOpeningModal from '../../components/inventory/ChestOpeningModal'; const MIN_LEVEL_FOR_CHEST = 11; const RARITY_ORDER = ['unique', 'legendary', 'epic', 'rare', 'common']; +// Auto-équipement du cosmétique Unique juste après sa réclamation — même +// logique que l'équipement manuel (useAvatarFrame persiste en local + +// synchronise le backend ; setProfileThemeId reste une préférence locale). +const CLAIM_EQUIP_ACTION = { + PROFILE_FRAME_BLOOD_BOND: (ctx) => ctx.selectShape('dragonfang'), + FRAME_COLOR_BLOOD_SANG: (ctx) => ctx.selectColor('bloodsang'), + THEME_UNLOCK_BLOOD_SANG: (ctx) => ctx.setProfileThemeId('blood_sang'), +}; + // ─── InventoryScreen ────────────────────────────────────────────────────────── // Inventaire RPG (Brique II) : coffres à ouvrir, consommables par rareté, // cosmétiques Uniques. Chaque carte entre en scène en cascade (stagger). @@ -24,6 +35,8 @@ export default function InventoryScreen({ navigation }) { const { user, refetch } = useUser(); const { addBonusXp, totalXP } = useWorkoutLogs(); const { showToast } = useToast(); + const { selectShape, selectColor } = useAvatarFrame(); + const { setProfileThemeId } = useDevSettings(); const [busy, setBusy] = useState(false); const [chestModal, setChestModal] = useState({ visible: false, drawnItem: null }); @@ -91,6 +104,28 @@ export default function InventoryScreen({ navigation }) { } }; + // Réclame un cosmétique Unique (cadre/couleur/thème) : consomme l'item côté + // backend, débloque définitivement le cosmétique, puis l'équipe/sélectionne + // aussitôt côté front pour un retour immédiat. + const handleClaimItem = async (itemType) => { + if (busy) return; + setBusy(true); + try { + const res = await claimUniqueItem(itemType); + if (res.success) { + showToast(`${ITEM_CATALOG[itemType]?.name ?? itemType} réclamé !`, 'success'); + const equip = CLAIM_EQUIP_ACTION[itemType]; + if (equip) await equip({ selectShape, selectColor, setProfileThemeId }); + refetch(); + } + } catch (error) { + if (error.isSessionExpired) return; + showToast(error.data?.message || 'Impossible de réclamer cet objet.', 'error'); + } finally { + setBusy(false); + } + }; + const closeChestModal = () => { setChestModal({ visible: false, drawnItem: null }); refetch(); @@ -136,6 +171,7 @@ export default function InventoryScreen({ navigation }) { index={index} busy={busy} onUse={() => handleUseItem(entry.itemType)} + onClaim={() => handleClaimItem(entry.itemType)} /> )) )} @@ -184,7 +220,7 @@ function ChestCard({ count, locked, busy, onOpen }) { {locked ? `Atteins le niveau ${MIN_LEVEL_FOR_CHEST} (Rang Initié) pour les débloquer.` - : 'Cumule 5 h de séance pour gagner un coffre.'} + : 'Cumule 2 h de séance pour gagner un coffre.'} @@ -204,7 +240,7 @@ function ChestCard({ count, locked, busy, onOpen }) { // ─── Carte item avec entrée en cascade ─────────────────────────────────────── -function StaggeredItemCard({ entry, index, busy, onUse }) { +function StaggeredItemCard({ entry, index, busy, onUse, onClaim }) { const slide = useRef(new Animated.Value(24)).current; const fade = useRef(new Animated.Value(0)).current; @@ -225,11 +261,20 @@ function StaggeredItemCard({ entry, index, busy, onUse }) { return ( @@ -254,6 +299,17 @@ function StaggeredItemCard({ entry, index, busy, onUse }) { Utiliser )} + + {meta.claimable && ( + + Réclamer + + )} ); } diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js index 8181e31..545c0c7 100644 --- a/front/src/screens/Profile/ProfileScreen.js +++ b/front/src/screens/Profile/ProfileScreen.js @@ -31,6 +31,7 @@ import { useDevSettings } from '../../hooks/useDevSettings'; import { useFeaturedTrophies } from '../../hooks/useFeaturedTrophies'; import { getTheme } from '../../data/profileThemes'; import { evaluateTrophies, ULTIMATE_TROPHY } from '../../data/trophyCatalog'; +import { syncLocalAchievements } from '../../services/reward.service'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; @@ -43,7 +44,8 @@ import StreakBadge from '../../components/profile/StreakBadge'; import BorderPicker from '../../components/profile/BorderPicker'; -function buildBgGradient(isGod, isLegend, isElite) { +function buildBgGradient(isGod, isLegend, isElite, isBlood) { + if (isBlood) return ['#0A0000', '#150202', Colors.bgAbyss, '#0A0000']; if (isGod) return ['#0A0800', '#100D02', Colors.bgAbyss, '#08080E']; if (isLegend) return [Colors.bgAbyss, '#0C0816', '#0D0A1A', Colors.bgAbyss]; if (isElite) return [Colors.bgAbyss, '#0A0A18', '#0D0D1C', Colors.bgAbyss]; @@ -139,6 +141,19 @@ export default function ProfileScreen({ navigation }) { return [...base, { ...ULTIMATE_TROPHY, unlocked: ultimateUnlocked, naturalUnlocked: ultimateUnlocked }]; }, [level, totalSessions, logs, totalXP, trophyOverrides]); + // Synchronise les trophées locaux NATURELLEMENT débloqués (jamais les + // overrides God Mode) vers le backend, pour qu'ils apparaissent sur le + // profil public consulté par les amis. Dédupliqué par un ref (déclenche au + // plus une requête par changement réel de l'ensemble débloqué). + const lastSyncedRef = useRef(''); + useEffect(() => { + const unlockedIds = evaluatedTrophies.filter((t) => t.naturalUnlocked).map((t) => t.id); + const key = [...unlockedIds].sort().join(','); + if (key === lastSyncedRef.current || unlockedIds.length === 0) return; + lastSyncedRef.current = key; + syncLocalAchievements(unlockedIds).catch(() => { lastSyncedRef.current = ''; }); + }, [evaluatedTrophies]); + // Active profile theme (null when 'auto' or not set) const activeTheme = useMemo(() => { const t = getTheme(profileThemeId); @@ -146,9 +161,10 @@ export default function ProfileScreen({ navigation }) { }, [profileThemeId]); // Background variant: theme overrides level-based flags - const isElite = activeTheme ? ['elite','legend','god'].includes(activeTheme.bgVariant) : level >= 91; - const isLegend = activeTheme ? ['legend','god'].includes(activeTheme.bgVariant) : level >= 171; - const isGod = activeTheme ? activeTheme.bgVariant === 'god' : level >= 200; + const isElite = activeTheme ? ['elite','legend','god','blood'].includes(activeTheme.bgVariant) : level >= 91; + const isLegend = activeTheme ? ['legend','god','blood'].includes(activeTheme.bgVariant) : level >= 171; + const isGod = activeTheme ? activeTheme.bgVariant === 'god' : level >= 200; + const isBlood = activeTheme?.bgVariant === 'blood'; // rank is used for QuickBtn accent — also override with theme const rank = useMemo(() => { @@ -156,7 +172,7 @@ export default function ProfileScreen({ navigation }) { return { ...realRank, color: activeTheme.accentColor || realRank.color }; }, [activeTheme, realRank]); - const bgColors = buildBgGradient(isGod, isLegend, isElite); + const bgColors = buildBgGradient(isGod, isLegend, isElite, isBlood); const topPad = insets.top + 16; if (profileLoading && !user && logsLoading) { @@ -221,7 +237,7 @@ export default function ProfileScreen({ navigation }) { /> @@ -319,6 +335,7 @@ export default function ProfileScreen({ navigation }) { currentShapeId={shapeId} currentColorId={colorId} playerLevel={level} + unlockedCosmetics={user?.unlockedCosmetics ?? []} userInitial={userInitial} onSelectShape={selectShape} onSelectColor={selectColor} diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 11c80b4..b9566ad 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -22,6 +22,10 @@ import { } from '../../services/stats.service'; import { PROFILE_THEMES, isThemeLocked } from '../../data/profileThemes'; import { TROPHY_CATALOG, TROPHY_CATEGORIES, ULTIMATE_TROPHY, evaluateTrophies } from '../../data/trophyCatalog'; +import { COLLECTION_CATEGORY, BACKEND_CATEGORY_MAP } from '../../data/backendTrophyCategories'; +import { getAchievements } from '../../services/reward.service'; +import { normalizeAchievement } from '../../components/profile/AchievementShowcase'; +import { TrophyIcon } from '../../components/profile/TrophySlot'; import { useDevSettings } from '../../hooks/useDevSettings'; import { useFocusEffect } from '@react-navigation/native'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; @@ -33,7 +37,10 @@ import { scheduleDailyReminder, cancelDailyReminder, } from '../../services/notificationService'; -import { syncBackendLevel, giveChests, generateMockSocial } from '../../services/debug.service'; +import { + syncBackendLevel, giveChests, generateMockSocial, giveAllItems, + simulateChestsOpened, simulateReferral, simulateBirthday, +} from '../../services/debug.service'; const UNIT_WEIGHT_KEY = 'athly:unit:weight:v1'; const UNIT_DIST_KEY = 'athly:unit:distance:v1'; @@ -50,7 +57,7 @@ const DEV_TAP_TARGET = 10; export default function SettingsScreen({ navigation }) { const { signOut } = useAuth(); - const { setUser, refetch: refetchUser } = useUser(); + const { user, setUser, refetch: refetchUser } = useUser(); const { showToast } = useToast(); const { totalXP, sessionLogs, activityLogs, refresh, clearAll: clearWorkoutLogs } = useWorkoutLogs(); const { clearAll: clearSavedWorkouts } = useSavedWorkouts(); @@ -348,6 +355,82 @@ export default function SettingsScreen({ navigation }) { } }, [showFeedback]); + // Injecte 1 exemplaire de CHAQUE objet existant (consommables + cosmétiques + // Uniques réclamables) pour tout tester en un clic. Bloqué en production (404). + const handleGiveAllItems = useCallback(async () => { + try { + setSimLoading(true); + const res = await giveAllItems(); + await refetchUser(); + showFeedback(res.message || 'Tous les objets ajoutés ✓'); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback, refetchUser]); + + // Formatte un retour "+N trophée(s) débloqué(s)" à partir de newlyUnlocked, + // partagé par les 3 simulateurs de trophées backend ci-dessous. + const feedbackWithUnlocks = useCallback((baseMessage, newlyUnlocked) => { + if (newlyUnlocked && newlyUnlocked.length > 0) { + return `${baseMessage} · ${newlyUnlocked.length} trophée(s) débloqué(s) ✓`; + } + return `${baseMessage} (déjà débloqué)`; + }, []); + + // Incrémente totalChestsOpened sans vraies ouvertures de coffre — permet de + // tester les trophées CHEST_1…CHEST_200 et le thème Rouge Sang (100 coffres) + // sans enchaîner des dizaines d'ouvertures manuelles. Bloqué en prod (404). + const handleSimulateChests = useCallback(async () => { + const n = parseInt(targetChests, 10); + if (!targetChests || isNaN(n) || n < 1 || n > 250) { showFeedback('Quantité invalide (1–250)'); return; } + try { + setSimLoading(true); + const res = await simulateChestsOpened(n); + await refetchUser(); + showFeedback(feedbackWithUnlocks(`+${n} coffre(s) simulé(s) (total : ${res.totalChestsOpened})`, res.newlyUnlocked)); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [targetChests, showFeedback, refetchUser, feedbackWithUnlocks]); + + // Crée un filleul factice pour débloquer FIRST_REFERRAL (parrainage) sans + // avoir à créer un vrai second compte. Bloqué en production (404). + const handleSimulateReferral = useCallback(async () => { + try { + setSimLoading(true); + const res = await simulateReferral(); + await refetchUser(); + showFeedback(feedbackWithUnlocks('Parrainage simulé', res.newlyUnlocked)); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback, refetchUser, feedbackWithUnlocks]); + + // Force la date de naissance à aujourd'hui pour débloquer BIRTHDAY_SET et + // BIRTHDAY_CELEBRATED sans attendre le vrai jour J. Bloqué en prod (404). + const handleSimulateBirthday = useCallback(async () => { + try { + setSimLoading(true); + const res = await simulateBirthday(); + await refetchUser(); + showFeedback(feedbackWithUnlocks('Anniversaire simulé', res.newlyUnlocked)); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback, refetchUser, feedbackWithUnlocks]); + const handleLockDevSection = useCallback(async () => { setDevVisible(false); setTapCount(0); @@ -387,9 +470,45 @@ export default function SettingsScreen({ navigation }) { () => evaluateTrophies(level, sessionLogs.length, sessionLogs, totalXP, trophyOverrides), [level, sessionLogs, totalXP, trophyOverrides], ); - const ultimateUnlocked = evaluatedTrophies.every((t) => t.unlocked); - const unlockedThemes = PROFILE_THEMES.filter((t) => !isThemeLocked(t, level)); + // Trophées backend V2 (anniversaire, collection d'objets, coffres, social) — + // sans eux, le panneau God Mode n'affichait que les 40 trophées locaux. + // Lecture seule ici : pas d'override dev possible sur des données serveur. + const [backendAchievements, setBackendAchievements] = useState([]); + useFocusEffect( + useCallback(() => { + let cancelled = false; + getAchievements() + .then((res) => { if (!cancelled) setBackendAchievements(res.achievements ?? []); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []), + ); + + const normalizedBackendTrophies = useMemo( + () => backendAchievements.map((a) => ({ + ...normalizeAchievement(a), + category: BACKEND_CATEGORY_MAP[a.category] || 'collection', + isBackend: true, + })), + [backendAchievements], + ); + + const allTrophyCategories = useMemo(() => [...TROPHY_CATEGORIES, COLLECTION_CATEGORY], []); + const fullTrophyList = useMemo( + () => [...evaluatedTrophies, ...normalizedBackendTrophies], + [evaluatedTrophies, normalizedBackendTrophies], + ); + + // Le Trophée Ultime exige la collection complète (local + backend) — voir + // la même règle dans TrophyRoomScreen.js. + const ultimateUnlocked = backendAchievements.length > 0 && fullTrophyList.every((t) => t.unlocked); + + const unlockedCosmetics = user?.unlockedCosmetics ?? []; + const totalChestsOpened = user?.totalChestsOpened ?? 0; + const unlockedThemes = PROFILE_THEMES.filter((t) => !isThemeLocked(t, level, unlockedCosmetics)); + const bloodSangTheme = PROFILE_THEMES.find((t) => t.id === 'blood_sang'); + const bloodSangLocked = bloodSangTheme ? isThemeLocked(bloodSangTheme, level, unlockedCosmetics) : false; return ( @@ -441,7 +560,7 @@ export default function SettingsScreen({ navigation }) { {PROFILE_THEMES.map((theme) => { - const locked = isThemeLocked(theme, level); + const locked = isThemeLocked(theme, level, unlockedCosmetics); const selected = profileThemeId === theme.id; return ( + {bloodSangLocked && ( + + + + Rouge Sang : déblocable après avoir ouvert 100 coffres (Actuel : {Math.min(totalChestsOpened, 100)}/100) + + + )} {/* ═══ NOTIFICATIONS ════════════════════════════════════════════════════ */} @@ -570,7 +697,13 @@ export default function SettingsScreen({ navigation }) { keyboardType="number-pad" placeholder="1" placeholderTextColor="rgba(255,215,0,0.3)" returnKeyType="done" /> + + + « + Coffre(s) » ajoute des CHEST_KEY à ouvrir manuellement. « Simuler ouverts » + incrémente directement le compteur de coffres ouverts (trophées CHEST_1…CHEST_200, + thème Rouge Sang à 100). + + + + + + Ajoute 1 exemplaire de chaque objet (consommables + cosmétiques Uniques + réclamables) dans l'inventaire, pour tout tester d'un coup. + + + + + + + Débloquent respectivement le trophée « Recruteur Athly » (parrainage) et les + trophées anniversaire, sans attendre un vrai filleul ou le vrai jour J. + {/* ── SIMULATION ── */} @@ -611,7 +765,7 @@ export default function SettingsScreen({ navigation }) { {ULTIMATE_TROPHY.label} - {ultimateUnlocked ? '✓ Débloqué !' : `${evaluatedTrophies.filter(t => t.unlocked).length}/${TROPHY_CATALOG.length}`} + {ultimateUnlocked ? '✓ Débloqué !' : `${fullTrophyList.filter(t => t.unlocked).length}/${fullTrophyList.length}`} @@ -651,8 +805,8 @@ export default function SettingsScreen({ navigation }) { /> - {trophyExpanded && TROPHY_CATEGORIES.map((cat) => { - const catTrophies = evaluatedTrophies.filter((t) => t.category === cat.id); + {trophyExpanded && allTrophyCategories.map((cat) => { + const catTrophies = fullTrophyList.filter((t) => t.category === cat.id); if (catTrophies.length === 0) return null; return ( @@ -660,7 +814,7 @@ export default function SettingsScreen({ navigation }) { {catTrophies.map((t) => ( - + @@ -668,18 +822,30 @@ export default function SettingsScreen({ navigation }) { {t.condition} - - {!t.naturalUnlocked && trophyOverrides[t.id] === true && ( - DEV - )} - setTrophyOverride(t.id, val === t.naturalUnlocked ? null : val)} - trackColor={{ false: 'rgba(255,255,255,0.10)', true: t.color + 'AA' }} - thumbColor={t.unlocked ? t.color : '#888'} - style={styles.trophySwitch} - /> - + {t.isBackend ? ( + // Trophée de compte (serveur) : pas d'override dev possible, + // affiche uniquement le statut réel. + + + + ) : ( + + {!t.naturalUnlocked && trophyOverrides[t.id] === true && ( + DEV + )} + setTrophyOverride(t.id, val === t.naturalUnlocked ? null : val)} + trackColor={{ false: 'rgba(255,255,255,0.10)', true: t.color + 'AA' }} + thumbColor={t.unlocked ? t.color : '#888'} + style={styles.trophySwitch} + /> + + )} ))} @@ -986,6 +1152,11 @@ const styles = StyleSheet.create({ themeLockOverlay:{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' }, themeLabel: { color: Colors.textMuted, fontSize: 8, fontWeight: '600', textAlign: 'center' }, themeLabelLocked:{ color: Colors.textMuted }, + themeSpecialHint: { + flexDirection: 'row', alignItems: 'flex-start', gap: 6, + paddingHorizontal: 12, paddingBottom: 12, paddingTop: 2, + }, + themeSpecialHintTxt: { flex: 1, color: Colors.textMuted, fontSize: 10.5, lineHeight: 14 }, // ── God Mode console ─────────────────────────────────────────────────────── devConsole: { marginTop: 10, backgroundColor: GOLD_BG, borderRadius: 16, borderWidth: 1, borderColor: GOLD_BDR, padding: 16, gap: 12 }, diff --git a/front/src/screens/Profile/TrophyRoomScreen.js b/front/src/screens/Profile/TrophyRoomScreen.js index d64ea97..78279a7 100644 --- a/front/src/screens/Profile/TrophyRoomScreen.js +++ b/front/src/screens/Profile/TrophyRoomScreen.js @@ -21,6 +21,24 @@ import { useFeaturedTrophies, MAX_FEATURED } from '../../hooks/useFeaturedTrophi import { useFocusEffect } from '@react-navigation/native'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; +import { getAchievements } from '../../services/reward.service'; +import { normalizeAchievement } from '../../components/profile/AchievementShowcase'; +import { TrophyIcon } from '../../components/profile/TrophySlot'; +import { COLLECTION_CATEGORY, BACKEND_CATEGORY_MAP } from '../../data/backendTrophyCategories'; + +// Masque un trophée "secret" tant qu'il n'est pas débloqué — même logique que +// le masquage backend (buildAchievementsView) appliqué au profil d'un ami : +// la surprise doit rester entière, y compris pour soi-même. +function maskIfSecret(trophy) { + if (trophy.category !== 'secret' || trophy.unlocked) return trophy; + return { + ...trophy, + label: '???', + condition: 'Trophée secret', + epicDesc: 'Ce trophée est encore secret. Continuez à vous entraîner pour le découvrir.', + icon: 'help-circle', + }; +} // ─── Glow config per tier ───────────────────────────────────────────────────── @@ -34,7 +52,7 @@ const TIER_GLOW = { // ─── UltimateTile ───────────────────────────────────────────────────────────── -function UltimateTile({ unlocked, onPress }) { +function UltimateTile({ unlocked, unlockedCount, totalCount, onPress }) { const scale = useRef(new Animated.Value(1)).current; const gleamX = useRef(new Animated.Value(-140)).current; const glow = useRef(new Animated.Value(0)).current; @@ -141,7 +159,7 @@ function UltimateTile({ unlocked, onPress }) { {!unlocked && ( - {TROPHY_CATALOG.length - 0} trophées requis · Progressez pour débloquer + {unlockedCount}/{totalCount} trophées débloqués · Progressez pour tous les débloquer )} @@ -157,6 +175,7 @@ export default function TrophyRoomScreen({ navigation }) { const { toggleFeatured, isFeatured } = useFeaturedTrophies(); const [selected, setSelected] = useState(null); const [activeFilter, setFilter] = useState('all'); + const [backendAchievements, setBackendAchievements] = useState([]); const { level } = useMemo(() => xpToLevel(totalXP), [totalXP]); const totalSessions = logs.length; @@ -166,16 +185,60 @@ export default function TrophyRoomScreen({ navigation }) { [level, totalSessions, logs, totalXP, trophyOverrides], ); - const unlockedCount = evaluated.filter((t) => t.unlocked).length; - const ultimateUnlocked = evaluated.every((t) => t.unlocked); + // Trophées backend V2 (anniversaire, collection d'objets, coffres, social) — + // chargés à part car ils dépendent du compte serveur, pas des logs locaux. + // Échec silencieux : la Salle des Trophées reste utilisable hors-ligne avec + // uniquement le catalogue local. + useFocusEffect( + useCallback(() => { + let cancelled = false; + getAchievements() + .then((res) => { if (!cancelled) setBackendAchievements(res.achievements ?? []); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []), + ); + + const normalizedBackend = useMemo( + () => backendAchievements.map((a) => ({ + ...normalizeAchievement(a), + category: BACKEND_CATEGORY_MAP[a.category] || 'collection', + isBackend: true, + })), + [backendAchievements], + ); + + // Vue combinée (catalogue local + backend) pour l'affichage par catégorie. + // Les trophées "secret" verrouillés sont masqués (mêmes égards que le + // profil d'un ami : on ne se spoile pas soi-même la surprise). + const fullEvaluated = useMemo( + () => [...evaluated.map(maskIfSecret), ...normalizedBackend], + [evaluated, normalizedBackend], + ); + + const unlockedCount = fullEvaluated.filter((t) => t.unlocked).length; + const totalCatalogSize = TROPHY_CATALOG.length + backendAchievements.length; + // Le Trophée Ultime exige DÉSORMAIS la collection complète (local + backend) — + // tant que les trophées backend n'ont pas fini de charger, on le laisse + // verrouillé plutôt que de l'unlock prématurément sur le seul catalogue local. + const ultimateUnlocked = backendAchievements.length > 0 && fullEvaluated.every((t) => t.unlocked); const ultimateTrophy = useMemo(() => ({ ...ULTIMATE_TROPHY, unlocked: ultimateUnlocked }), [ultimateUnlocked]); + const allCategories = useMemo(() => [...TROPHY_CATEGORIES, COLLECTION_CATEGORY], []); + + // Rattache "Collection" au filtre "Spécial", sans muter les données source. + const filterTabs = useMemo(() => TROPHY_FILTER_TABS.map((tab) => ( + tab.id === 'special' && tab.categories + ? { ...tab, categories: [...tab.categories, 'collection'] } + : tab + )), []); + const visibleCategories = useMemo(() => { - if (activeFilter === 'all') return TROPHY_CATEGORIES; - const tab = TROPHY_FILTER_TABS.find(t => t.id === activeFilter); - if (!tab || !tab.categories) return TROPHY_CATEGORIES; - return TROPHY_CATEGORIES.filter(c => tab.categories.includes(c.id)); - }, [activeFilter]); + if (activeFilter === 'all') return allCategories; + const tab = filterTabs.find(t => t.id === activeFilter); + if (!tab || !tab.categories) return allCategories; + return allCategories.filter(c => tab.categories.includes(c.id)); + }, [activeFilter, allCategories, filterTabs]); // ─── Tutorial ─────────────────────────────────────────────────────────── const { @@ -190,9 +253,9 @@ export default function TrophyRoomScreen({ navigation }) { const firstTrophyId = useMemo(() => { if (visibleCategories.length === 0) return null; const firstCat = visibleCategories[0]; - const catTrophies = evaluated.filter((t) => t.category === firstCat.id); + const catTrophies = fullEvaluated.filter((t) => t.category === firstCat.id); return catTrophies.length > 0 ? catTrophies[0].id : null; - }, [visibleCategories, evaluated]); + }, [visibleCategories, fullEvaluated]); const scrollRef = useRef(null); @@ -226,14 +289,14 @@ export default function TrophyRoomScreen({ navigation }) { Salle des Trophées - {unlockedCount}/{TROPHY_CATALOG.length} débloqués + {unlockedCount}/{totalCatalogSize} débloqués {/* Filter tabs */} - {TROPHY_FILTER_TABS.map((tab) => { + {filterTabs.map((tab) => { const active = activeFilter === tab.id; return ( {ultimateUnlocked ? '1/1' : '0/1'} - setSelected(ultimateTrophy)} /> + setSelected(ultimateTrophy)} /> )} {/* ── Catégories standard ── */} {visibleCategories.map((cat) => { - const catTrophies = evaluated.filter((t) => t.category === cat.id); + const catTrophies = fullEvaluated.filter((t) => t.category === cat.id); if (catTrophies.length === 0) return null; const catUnlocked = catTrophies.filter((t) => t.unlocked).length; return ( @@ -345,7 +408,7 @@ function GalleryTile({ trophy, onPress, tutorialRef, tutorialOnLayout }) { ]}> {unlocked ? ( - + ) : ( @@ -367,7 +430,7 @@ function GalleryTile({ trophy, onPress, tutorialRef, tutorialOnLayout }) { // ─── TrophyDetailModal ──────────────────────────────────────────────────────── function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatured }) { - const { icon, label, condition, epicDesc, color, gradientColors, unlocked, tier } = trophy; + const { icon, label, condition, epicDesc, color, gradientColors, unlocked, tier, isBackend } = trophy; const scale = useRef(new Animated.Value(0.88)).current; const opacity = useRef(new Animated.Value(0)).current; @@ -388,7 +451,7 @@ function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatur {unlocked - ? + ? : } @@ -408,7 +471,7 @@ function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatur {unlocked ? epicDesc : "Accomplissez encore — ce trophée attend le guerrier que vous deviendrez."} - {unlocked && ( + {unlocked && !isBackend && ( { onToggleFeatured?.(); onClose(); }} @@ -420,6 +483,12 @@ function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatur )} + {unlocked && isBackend && ( + + + Trophée de compte + + )} Fermer diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index e71eff8..249ee8b 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -15,6 +15,7 @@ import HeroLevelCard from '../../components/profile/HeroLevelCard'; import StreakBadge from '../../components/profile/StreakBadge'; import EmberParticles from '../../components/profile/EmberParticles'; import AchievementShowcase from '../../components/profile/AchievementShowcase'; +import FriendShowcaseGrid from '../../components/profile/FriendShowcaseGrid'; // ─── FriendProfileScreen ────────────────────────────────────────────────────── // Profil public d'un ami (Brique III) : miroir en lecture seule de notre propre @@ -107,6 +108,11 @@ export default function FriendProfileScreen({ route, navigation }) { const equippedFrame = profile.user.equippedFrame || { shapeId: 'circle', colorId: 'none' }; + // Vitrine : IDs mis en avant résolus en entrées complètes du catalogue unifié + const showcasedEntries = (profile.showcasedAchievements || []) + .map((id) => (profile.achievements || []).find((a) => a.id === id)) + .filter(Boolean); + return ( @@ -152,6 +158,9 @@ export default function FriendProfileScreen({ route, navigation }) { )} + {/* ── Vitrine de l'ami : ses trophées mis en avant (mêmes TrophySlot que le profil) ── */} + + {/* ── Statut d'amitié ── */} diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index d33b2d0..85dc0ec 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -10,6 +10,7 @@ import { useToast } from '../../context/ToastContext'; import { useUser } from '../../context/UserContext'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import ConfirmModal from '../../components/common/ConfirmModal'; +import FriendshipLevelUpModal from '../../components/social/FriendshipLevelUpModal'; import { searchUsers, sendFriendRequest, acceptFriendRequest, declineFriendRequest, getFriendsList, getPendingRequests, getLeaderboard, @@ -50,12 +51,38 @@ export default function SocialScreen({ navigation }) { // ── Confirmation quitter le groupe ── const [leaveConfirmVisible, setLeaveConfirmVisible] = useState(false); + // ── Célébration montée de niveau d'amitié ────────────────────────────────── + // Comparaison du niveau d'amitié de chaque ami entre deux loadAll() : toute + // hausse détectée (déclenchée par une validation de streak de groupe) est + // mise en file et célébrée une par une. Le tout premier chargement mémorise + // sans célébrer (évite un faux déclenchement au démarrage — même garde que + // LevelUpCelebration.js). + const previousFriendshipLevels = useRef(new Map()); + const [levelUpQueue, setLevelUpQueue] = useState([]); + const [activeLevelUp, setActiveLevelUp] = useState(null); + + // ── Popup "objet Unique à réclamer" (streak de groupe 30j à 5 membres) ──── + const [bloodSangUnlockedVisible, setBloodSangUnlockedVisible] = useState(false); + const loadAll = useCallback(async () => { try { const [friendsRes, pendingRes, boardRes, groupRes] = await Promise.all([ getFriendsList(), getPendingRequests(), getLeaderboard(), getMyGroup(), ]); - setFriends(friendsRes.friends ?? []); + const incomingFriends = friendsRes.friends ?? []; + const newlyLeveledUp = []; + for (const f of incomingFriends) { + const prevLevel = previousFriendshipLevels.current.get(f.friendshipId); + if (prevLevel !== undefined && f.friendshipLevel > prevLevel) { + newlyLeveledUp.push({ pseudo: f.user.pseudo, level: f.friendshipLevel }); + } + previousFriendshipLevels.current.set(f.friendshipId, f.friendshipLevel); + } + if (newlyLeveledUp.length > 0) { + setLevelUpQueue((q) => [...q, ...newlyLeveledUp]); + } + + setFriends(incomingFriends); setPending(pendingRes.requests ?? []); setLeaderboard(boardRes.leaderboard ?? []); setGroup(groupRes.group ?? null); @@ -70,6 +97,14 @@ export default function SocialScreen({ navigation }) { } }, [showToast]); + // Défile la file un item à la fois — jamais deux modales superposées. + useEffect(() => { + if (!activeLevelUp && levelUpQueue.length > 0) { + setActiveLevelUp(levelUpQueue[0]); + setLevelUpQueue((q) => q.slice(1)); + } + }, [activeLevelUp, levelUpQueue]); + useFocusEffect(useCallback(() => { loadAll(); }, [loadAll])); const onRefresh = () => { setRefreshing(true); loadAll(); }; @@ -187,6 +222,7 @@ export default function SocialScreen({ navigation }) { if (res.groupBonus?.bonusXp > 0) { addBonusXp('Bonus de groupe', res.groupBonus.bonusXp); } + if (res.bloodSangUnlocked) setBloodSangUnlockedVisible(true); } else if (res.alreadyValidated) { showToast('Déjà validée aujourd\'hui ✅', 'success'); } else { @@ -219,6 +255,27 @@ export default function SocialScreen({ navigation }) { }} onCancel={() => setLeaveConfirmVisible(false)} /> + + { + setBloodSangUnlockedVisible(false); + navigation.navigate('ProfileTab', { screen: 'Inventory' }); + }} + onCancel={() => setBloodSangUnlockedVisible(false)} + /> + + setActiveLevelUp(null)} + /> ); } diff --git a/front/src/services/debug.service.js b/front/src/services/debug.service.js index 38f96d9..4e2f49f 100644 --- a/front/src/services/debug.service.js +++ b/front/src/services/debug.service.js @@ -20,3 +20,30 @@ export async function generateMockSocial() { const res = await API.post('/debug/godmode/mock-social'); return res.data; } + +// Injecte 1 exemplaire de CHAQUE objet existant (consommables + cosmétiques +// Uniques réclamables) dans l'inventaire, pour tout tester en un clic. +export async function giveAllItems() { + const res = await API.post('/debug/godmode/give-all-items'); + return res.data; +} + +// Simule `amount` coffres ouverts (sans vraies ouvertures) — débloque les +// trophées gradués CHEST_1…CHEST_200 et le thème Rouge Sang au palier 100. +export async function simulateChestsOpened(amount = 1) { + const res = await API.post('/debug/godmode/simulate-chests-opened', { amount }); + return res.data; +} + +// Crée un filleul factice (referredBy) pour débloquer FIRST_REFERRAL. +export async function simulateReferral() { + const res = await API.post('/debug/godmode/simulate-referral'); + return res.data; +} + +// Force la date de naissance à aujourd'hui pour débloquer BIRTHDAY_SET +// et BIRTHDAY_CELEBRATED sans attendre le vrai jour J. +export async function simulateBirthday() { + const res = await API.post('/debug/godmode/simulate-birthday'); + return res.data; +} diff --git a/front/src/services/inventory.service.js b/front/src/services/inventory.service.js index 4b26491..bd9a05c 100644 --- a/front/src/services/inventory.service.js +++ b/front/src/services/inventory.service.js @@ -1,4 +1,5 @@ import API from '../api/api'; +import { Colors } from '../constants/theme'; // ─── Inventaire & Coffres (Brique II) ───────────────────────────────────────── @@ -40,17 +41,30 @@ export const ITEM_CATALOG = { }, PROFILE_FRAME_BLOOD_BOND: { name: 'Cadre "Lien de Sang"', icon: 'shield', rarity: 'unique', - description: "Cosmétique Unique (niveau d'amitié 5). Introuvable en coffre.", usable: false, + description: "Cosmétique Unique (niveau d'amitié 5). Introuvable en coffre.", + usable: false, claimable: true, + }, + FRAME_COLOR_BLOOD_SANG: { + name: 'Couleur "Rouge Sang"', icon: 'color-palette', rarity: 'unique', + description: "Cosmétique Unique (streak de groupe 30 jours à 5). Introuvable en coffre.", + usable: false, claimable: true, + }, + THEME_UNLOCK_BLOOD_SANG: { + name: 'Thème "Rouge Sang"', icon: 'color-wand', rarity: 'unique', + description: "Cosmétique Unique (100 coffres ouverts). Introuvable en coffre.", + usable: false, claimable: true, }, }; -// Couleurs par rareté — alignées sur la palette du jeu (theme.js) +// Couleurs par rareté — alignées sur la palette du jeu (theme.js). +// unique = Rouge Sang premium (voir Colors.uniqueBlood*), pas or : distingue +// visuellement le palier ultime de tout ce qui est légendaire/doré. export const RARITY_META = { common: { label: 'Commun', color: '#9AA0AE' }, rare: { label: 'Rare', color: '#3B82F6' }, epic: { label: 'Épique', color: '#A855F7' }, legendary: { label: 'Légendaire', color: '#FE7439' }, - unique: { label: 'Unique', color: '#FFD700' }, + unique: { label: 'Unique', color: Colors.uniqueBlood, gradient: [Colors.uniqueBloodBright, Colors.uniqueBlood, Colors.uniqueBloodDeep], glow: Colors.uniqueBloodGlow }, }; export async function openChest() { @@ -62,3 +76,11 @@ export async function useItem(itemType) { const res = await API.post('/inventory/item/use', { itemType }); return res.data; } + +// Réclame un cosmétique Unique en attente (consomme l'item d'inventaire, +// débloque définitivement le cosmétique et l'équipe automatiquement côté +// backend). Voir back/controllers/inventory.controller.js → claimUniqueItem. +export async function claimUniqueItem(itemType) { + const res = await API.post('/inventory/claim', { itemType }); + return res.data; +} diff --git a/front/src/services/profile.service.js b/front/src/services/profile.service.js index 3010618..05630f4 100644 --- a/front/src/services/profile.service.js +++ b/front/src/services/profile.service.js @@ -7,3 +7,9 @@ export async function updateEquippedFrame(shapeId, colorId) { const res = await API.put('/users/me/frame', { shapeId, colorId }); return res.data; } + +// Met à jour la vitrine de trophées mis en avant sur le profil public (max 3). +export async function updateShowcase(achievementIds) { + const res = await API.put('/users/me/showcase', { achievementIds }); + return res.data; +} diff --git a/front/src/services/reward.service.js b/front/src/services/reward.service.js index cc4b60e..6a6f5b4 100644 --- a/front/src/services/reward.service.js +++ b/front/src/services/reward.service.js @@ -17,3 +17,11 @@ export async function getAchievements() { const res = await API.get('/rewards/achievements'); return res.data; } + +// Synchronise les trophées du catalogue LOCAL (V1, évalués depuis les logs de +// séances AsyncStorage) débloqués côté client, pour qu'ils apparaissent sur +// le profil public consulté par les amis. Additif — jamais destructeur. +export async function syncLocalAchievements(ids) { + const res = await API.put('/rewards/achievements/sync', { ids }); + return res.data; +} From a89467c8d2ac5c59bcbe323424f980b92378768b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Wed, 8 Jul 2026 11:06:58 +0200 Subject: [PATCH 15/31] =?UTF-8?q?fix:=20r=C3=A9sout=20les=20doublons=20de?= =?UTF-8?q?=20fusion=20(sync=20troph=C3=A9es,=20vitrine,=20showcase)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/controllers/user.controller.js | 19 ----------------- back/routes/reward.routes.js | 4 ---- back/services/user.service.js | 24 ---------------------- back/tests/deepIntegration.test.js | 2 +- front/src/screens/Profile/ProfileScreen.js | 1 - 5 files changed, 1 insertion(+), 49 deletions(-) diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index 2a74696..a21d874 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -86,23 +86,4 @@ exports.updateFrame = async (req, res, next) => { } catch (error) { next(error); } -}; - -/** - * METTRE À JOUR LA VITRINE DE TROPHÉES - * Trophées mis en avant sur le profil public (max 3, catalogue combiné). - */ -exports.updateShowcase = async (req, res, next) => { - try { - const { achievementIds } = req.body; - const user = await userService.updateShowcase(req.user.id, achievementIds); - - res.status(200).json({ - success: true, - message: "Vitrine mise à jour avec succès", - showcasedAchievements: user.showcasedAchievements, - }); - } catch (error) { - next(error); - } }; \ No newline at end of file diff --git a/back/routes/reward.routes.js b/back/routes/reward.routes.js index 605e993..a4c094e 100644 --- a/back/routes/reward.routes.js +++ b/back/routes/reward.routes.js @@ -24,8 +24,4 @@ router.put('/achievements/sync', reward.syncLocalAchievements); // (openChest, acceptFriendRequest, checkAndUpdateGroupStreaks…) router.post('/check', reward.checkAchievements); -// Synchronise les trophées du catalogue LOCAL (V1) débloqués côté client, -// pour qu'ils apparaissent sur le profil public consulté par les amis. -router.put('/achievements/sync', reward.syncLocalAchievements); - module.exports = router; diff --git a/back/services/user.service.js b/back/services/user.service.js index 03bf539..b3ae31c 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -100,30 +100,6 @@ class UserService { return updatedUser; } - /** - * Met à jour la vitrine de trophées mis en avant sur le profil public. - * Filtre contre le catalogue combiné (backend + miroir local) : impossible - * de mettre en avant un id inexistant. Le Joi validator borne déjà à 3 ids. - * @param {string} userId - * @param {string[]} achievementIds - */ - async updateShowcase(userId, achievementIds) { - const { ACHIEVEMENT_CATALOG } = require('../controllers/reward.controller'); - const { LOCAL_TROPHY_CATALOG } = require('../data/localTrophyCatalog'); - - const validIds = achievementIds.filter( - (id) => Object.prototype.hasOwnProperty.call(ACHIEVEMENT_CATALOG, id) || Object.prototype.hasOwnProperty.call(LOCAL_TROPHY_CATALOG, id), - ); - - const updatedUser = await User.findByIdAndUpdate( - userId, - { $set: { showcasedAchievements: validIds.slice(0, 3) } }, - { new: true, runValidators: true }, - ).select('showcasedAchievements'); - if (!updatedUser) throw new Error('Utilisateur non trouvé.'); - return updatedUser; - } - async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/deepIntegration.test.js b/back/tests/deepIntegration.test.js index 7cad2b1..633dc80 100644 --- a/back/tests/deepIntegration.test.js +++ b/back/tests/deepIntegration.test.js @@ -70,7 +70,7 @@ describe('Intégration profonde — trophées sync, vitrine, leaderboard exos, p .send({ ids: ['ignition', 'titan', 'id_bidon', 'BIRTHDAY_SET'] }); expect(res.statusCode).toBe(200); - expect(res.body.synced).toBe(2); // ignition + titan + expect(res.body.synced).toEqual(expect.arrayContaining(['ignition', 'titan'])); expect(res.body.added).toBe(2); expect(res.body.ignored).toBe(2); // id_bidon + BIRTHDAY_SET (backend, non synchronisable) diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js index d4158c9..6c5ec35 100644 --- a/front/src/screens/Profile/ProfileScreen.js +++ b/front/src/screens/Profile/ProfileScreen.js @@ -32,7 +32,6 @@ import { useDevSettings } from '../../hooks/useDevSettings'; import { useFeaturedTrophies } from '../../hooks/useFeaturedTrophies'; import { getTheme } from '../../data/profileThemes'; import { evaluateTrophies, ULTIMATE_TROPHY } from '../../data/trophyCatalog'; -import { syncLocalAchievements } from '../../services/reward.service'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; From 0b0d1f817edf43a7c33403ba1bde638a86617feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Wed, 8 Jul 2026 12:59:17 +0200 Subject: [PATCH 16/31] =?UTF-8?q?feat(social):=20m=C3=A9t=C3=A9o=20des=20s?= =?UTF-8?q?=C3=A9ances,=20flux=20d'activit=C3=A9=20avec=20r=C3=A9actions,?= =?UTF-8?q?=20hall=20of=20shame,=20pings=20troll=20r=C3=A9els=20et=20s?= =?UTF-8?q?=C3=A9curit=C3=A9=20d'abandon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/app.js | 2 + back/controllers/activity.controller.js | 55 +++++ back/controllers/groupStreak.controller.js | 178 +++++++++++++- back/controllers/inventory.controller.js | 14 ++ back/controllers/user.controller.js | 16 ++ back/models/ActivityEvent.js | 45 ++++ back/models/StreakGroup.js | 6 + back/models/User.js | 15 ++ back/package-lock.json | 58 +++++ back/package.json | 1 + back/routes/activity.routes.js | 15 ++ back/routes/user.routes.js | 5 +- back/services/activity.service.js | 80 +++++++ back/services/exercise.service.js | 34 +++ back/services/push.service.js | 82 +++++++ back/services/user.service.js | 21 ++ back/tests/activityFeed.test.js | 224 ++++++++++++++++++ back/tests/groupStreak.test.js | 186 +++++++++++++++ back/validators/activity.validator.js | 9 + back/validators/user.validator.js | 5 + front/src/components/cards/ExerciseCard.js | 2 +- front/src/components/common/ErrorBoundary.js | 15 +- front/src/components/profile/BirthdayModal.js | 5 +- .../components/social/ActivityFeedModal.js | 214 +++++++++++++++++ .../social/FriendshipLevelUpModal.js | 2 +- .../src/components/web/DesktopInstallPage.js | 7 +- .../components/workouts/AddExerciseSheet.js | 2 +- .../workouts/InlineExerciseBlock.js | 2 +- .../workouts/MuscleHierarchyPicker.js | 2 +- front/src/constants/exerciseFilters.js | 21 +- front/src/data/workoutTemplates.js | 8 +- front/src/navigation/index.js | 19 +- .../src/screens/Profile/EditProfileScreen.js | 2 +- front/src/screens/Profile/InventoryScreen.js | 2 +- front/src/screens/Profile/SettingsScreen.js | 8 +- .../src/screens/Social/FriendProfileScreen.js | 2 +- front/src/screens/Social/SocialScreen.js | 112 +++++++-- .../screens/Workouts/CustomExercisesScreen.js | 2 +- .../Workouts/ManualWorkoutCreatorScreen.js | 2 +- .../screens/Workouts/WorkoutBuilderScreen.js | 2 +- .../src/screens/Workouts/WorkoutListScreen.js | 2 +- front/src/screens/Workouts/WorkoutScreen.js | 54 +++++ front/src/services/notificationService.js | 26 ++ front/src/services/profile.service.js | 8 + front/src/services/social.service.js | 12 + 45 files changed, 1515 insertions(+), 69 deletions(-) create mode 100644 back/controllers/activity.controller.js create mode 100644 back/models/ActivityEvent.js create mode 100644 back/routes/activity.routes.js create mode 100644 back/services/activity.service.js create mode 100644 back/services/push.service.js create mode 100644 back/tests/activityFeed.test.js create mode 100644 back/validators/activity.validator.js create mode 100644 front/src/components/social/ActivityFeedModal.js diff --git a/back/app.js b/back/app.js index a0430d2..2b84fff 100644 --- a/back/app.js +++ b/back/app.js @@ -15,6 +15,7 @@ const groupStreakRoutes = require("./routes/groupStreak.routes"); const rewardRoutes = require("./routes/reward.routes"); const referralRoutes = require("./routes/referral.routes"); const debugRoutes = require("./routes/debug.routes"); +const activityRoutes = require("./routes/activity.routes"); // --- Importation des middlewares --- const errorMiddleware = require("./middleware/error.middleware"); @@ -94,6 +95,7 @@ app.use("/api/groups", groupStreakRoutes); app.use("/api/rewards", rewardRoutes); app.use("/api/referral", referralRoutes); app.use("/api/debug", debugRoutes); +app.use("/api/activity", activityRoutes); // --- Gestion des erreurs --- // Route 404 diff --git a/back/controllers/activity.controller.js b/back/controllers/activity.controller.js new file mode 100644 index 0000000..f932f99 --- /dev/null +++ b/back/controllers/activity.controller.js @@ -0,0 +1,55 @@ +'use strict'; + +const User = require('../models/User'); +const { getUnseenFeedForUser, reactToEvent, REACTION_META } = require('../services/activity.service'); +const { sendPushToUser } = require('../services/push.service'); + +/** + * GET /api/activity/feed + * Retourne les événements non vus depuis le dernier appel, puis avance le + * curseur `lastActivityFeedCheckAt` — l'appel suivant ne remontera que les + * événements postérieurs à celui-ci (pas de doublon en cas d'ouvertures + * multiples de l'app dans la même journée). + */ +exports.getFeed = async (req, res, next) => { + try { + const me = await User.findById(req.user.id).select('lastActivityFeedCheckAt'); + const since = me?.lastActivityFeedCheckAt || null; + + const events = await getUnseenFeedForUser(req.user.id, since); + + await User.updateOne({ _id: req.user.id }, { $set: { lastActivityFeedCheckAt: new Date() } }); + + res.status(200).json({ success: true, events, reactionOptions: REACTION_META }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/activity/:eventId/react + * Enregistre une réaction (upsert par utilisateur) et notifie en push + * l'auteur de l'événement — c'est le coeur du côté "taquinerie" du flux. + */ +exports.react = async (req, res, next) => { + try { + const { eventId } = req.params; + const { emoji } = req.body; + + const { event, notifyActorId } = await reactToEvent(eventId, req.user.id, emoji); + + if (notifyActorId) { + const reactor = await User.findById(req.user.id).select('pseudo'); + const meta = REACTION_META[emoji]; + await sendPushToUser(notifyActorId, { + title: 'Athly', + body: `${reactor?.pseudo ?? 'Un ami'} a réagi ${meta.emoji} : « ${meta.label} »`, + data: { type: 'activity_reaction', eventId: String(event._id) }, + }); + } + + res.status(200).json({ success: true, reactions: event.reactions }); + } catch (error) { + next(error); + } +}; diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index 14d217e..d4322a9 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -8,17 +8,35 @@ const Workout = require('../models/Workout'); const { addUniqueItemOnce } = require('../services/inventory.service'); const { checkAndUnlockAchievements } = require('./reward.controller'); const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); +const { sendPushToUser } = require('../services/push.service'); // ─── Constantes ─────────────────────────────────────────────────────────────── +// Bouton "Secouer" (Section IV) : textes troll/passif-agressif façon Duolingo, +// tirés au sort à chaque secousse pour ne pas être répétitifs. +const SHAKE_TROLL_MESSAGES = [ + "Ah donc tu comptes nous abandonner comme ça ? Ok.", + "Tu vas briser la streak de l'équipe... tout le monde attend après toi. 👁️", + "On sait que tu es en ligne. On voit tout.", + "Ta séance ne va pas se faire toute seule, curieusement.", + "Le groupe compte sur toi. Ou pas, si tu préfères tout gâcher.", + "Petit rappel amical : tu es le maillon faible aujourd'hui.", +]; + const MAX_GROUP_SIZE = 5; // Streak de groupe (jours consécutifs) requise, à taille maximale (5 membres), // pour débloquer la couleur cosmétique Unique "Rouge Sang Unique". const BLOOD_SANG_STREAK_THRESHOLD = 30; -// Champs publics exposés pour un membre de groupe -const MEMBER_PUBLIC_FIELDS = 'pseudo level rank xp'; +// Champs publics exposés pour un membre de groupe (lastActiveAt alimente la +// Météo des séances — statut "Prêt") +const MEMBER_PUBLIC_FIELDS = 'pseudo level rank xp lastActiveAt'; + +// Météo des séances : une séance "draft"/"in_progress" plus vieille que cette +// fenêtre est considérée abandonnée plutôt qu'activement chronométrée — évite +// d'afficher ⚡ indéfiniment pour une séance oubliée en arrière-plan. +const WEATHER_ACTIVE_WINDOW_MS = 4 * 60 * 60 * 1000; // 4h // Seuils XP pour les 5 niveaux d'amitié — progression exponentielle (~4 mois) const FRIENDSHIP_XP_THRESHOLDS = [0, 100, 300, 700, 1500]; @@ -143,6 +161,135 @@ async function grantGroupXpBonus(memberId, bonusXp) { } } +// ── Météo des séances (Brique IV) ────────────────────────────────────────── +// 4 états stricts, du plus « chaud » au plus « froid » — le premier qui +// s'applique gagne : +// ✅ done : séance terminée aujourd'hui (status finished/completed). +// ⚡ active : séance en cours aujourd'hui (status draft/in_progress) +// dont le dernier `updatedAt` date de moins de 4h — au-delà, +// on considère la séance oubliée en arrière-plan plutôt que +// réellement chronométrée en direct. +// 🔥 ready : l'utilisateur a ouvert l'app aujourd'hui (lastActiveAt) +// sans avoir encore de séance du jour. +// 💤 sleeping : aucun des signaux ci-dessus. +/** + * Enrichit un tableau de membres populés (`MEMBER_PUBLIC_FIELDS`) avec leur + * `weatherStatus` du jour. Une seule requête `Workout.find` groupée pour tout + * le groupe (pas de N+1) — appelée à chaque `getMyGroup`, donc rafraîchie + * "proprement" à chaque focus d'écran côté front, sans avoir besoin d'un + * websocket dédié. + */ +async function attachWeatherStatuses(members) { + const todayStart = startOfToday(); + const memberIds = members.map((m) => m._id); + + const workouts = await Workout.find({ + user: { $in: memberIds }, + date: { $gte: todayStart }, + }).select('user status updatedAt'); + + const workoutsByUser = new Map(); + for (const w of workouts) { + const key = String(w.user); + if (!workoutsByUser.has(key)) workoutsByUser.set(key, []); + workoutsByUser.get(key).push(w); + } + + const now = Date.now(); + + return members.map((member) => { + const plain = typeof member.toObject === 'function' ? member.toObject() : member; + const todaysWorkouts = workoutsByUser.get(String(member._id)) || []; + + let weatherStatus = 'sleeping'; + + const hasFinishedToday = todaysWorkouts.some((w) => w.status === 'finished' || w.status === 'completed'); + const hasActiveNow = todaysWorkouts.some((w) => { + if (w.status !== 'draft' && w.status !== 'in_progress') return false; + const updatedAt = w.updatedAt ? new Date(w.updatedAt).getTime() : 0; + return now - updatedAt <= WEATHER_ACTIVE_WINDOW_MS; + }); + const wasActiveToday = plain.lastActiveAt && new Date(plain.lastActiveAt).getTime() >= todayStart.getTime(); + + if (hasFinishedToday) weatherStatus = 'done'; + else if (hasActiveNow) weatherStatus = 'active'; + else if (wasActiveToday) weatherStatus = 'ready'; + + return { ...plain, weatherStatus }; + }); +} + +// ── Hall of Shame (Brique IV) ────────────────────────────────────────────── +// Sans cron quotidien (aucun n'existe dans ce backend — voir StreakGroup.js), +// la rupture de streak est détectée "paresseusement" : au premier getMyGroup +// appelé après qu'une journée entière se soit écoulée sans validation. +// +// Un membre absent la veille est "couvert" (ne casse pas la streak) s'il +// possède au moins 1 Gel de Streak — celui-ci est alors consommé +// automatiquement. S'il en reste au moins un membre non couvert, la streak +// entière retombe à 0 et ce(s) membre(s) deviennent les "briseurs" affichés +// dans le bandeau Hall of Shame, jusqu'à la prochaine streak validée avec +// succès (voir checkAndUpdateGroupStreaks, qui vide shameBreakers). +async function detectAndApplyStreakBreak(group) { + if (!group.currentStreak || !group.lastValidatedDate) return group; + + const todayStart = startOfToday(); + const missedDayStart = new Date(group.lastValidatedDate); + missedDayStart.setHours(0, 0, 0, 0); + missedDayStart.setDate(missedDayStart.getDate() + 1); + + // La journée suivant la dernière validation n'est même pas encore terminée + // (aujourd'hui ou avant) : rien à détecter pour l'instant. + if (missedDayStart >= todayStart) return group; + + const missedDayEnd = new Date(missedDayStart); + missedDayEnd.setDate(missedDayEnd.getDate() + 1); + + const memberIds = group.members.map(String); + const workoutChecks = await Promise.all( + memberIds.map((memberId) => + Workout.findOne({ + user: memberId, + status: { $in: ['finished', 'completed'] }, + date: { $gte: missedDayStart, $lt: missedDayEnd }, + }).select('_id'), + ), + ); + + const absentMemberIds = memberIds.filter((_, i) => !workoutChecks[i]); + + if (absentMemberIds.length === 0) { + // Tout le monde avait validé cette journée-là (juste pas cliqué sur + // "Valider la streak") : on avance le curseur pour ne pas la re-traiter + // indéfiniment, sans casser ni incrémenter la streak. + group.lastValidatedDate = missedDayStart; + await group.save(); + return group; + } + + const breakers = []; + for (const memberId of absentMemberIds) { + const covered = await User.findOneAndUpdate( + { _id: memberId, streakGels: { $gt: 0 } }, + { $inc: { streakGels: -1 } }, + ); + if (!covered) breakers.push(memberId); + } + + if (breakers.length === 0) { + // Tous les absents couverts par un Gel de Streak — pareil, on avance le + // curseur pour ne pas re-consommer un gel supplémentaire au prochain appel. + group.lastValidatedDate = missedDayStart; + await group.save(); + return group; + } + + group.currentStreak = 0; + group.shameBreakers = breakers; + await group.save(); + return group; +} + // ───────────────────────────────────────────────────────────────────────────── // inviteToGroup POST /api/groups/invite // ───────────────────────────────────────────────────────────────────────────── @@ -368,9 +515,14 @@ exports.shakeMember = async (req, res, next) => { const target = await User.findById(memberId).select('pseudo'); const targetPseudo = target?.pseudo ?? memberId; + const me = await User.findById(myId).select('pseudo'); - // Simulation du push — remplacer par un appel réel au service de notifications - console.log(`[SHAKE] Push simulé → ${targetPseudo} n'a pas encore fait sa séance du jour.`); + const trollMessage = SHAKE_TROLL_MESSAGES[Math.floor(Math.random() * SHAKE_TROLL_MESSAGES.length)]; + await sendPushToUser(memberId, { + title: `${me?.pseudo ?? 'Un ami'} t'a secoué ! 🚨`, + body: trollMessage, + data: { type: 'shake', fromUserId: myId }, + }); return res.status(200).json({ success: true, @@ -450,6 +602,7 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { // ── Tous ont validé ────────────────────────────────────────────────────── group.currentStreak += 1; group.lastValidatedDate = new Date(); + group.shameBreakers = []; // une nouvelle streak efface le Hall of Shame await group.save(); // XP d'amitié par paire (C(n, 2) mises à jour) @@ -515,9 +668,10 @@ exports.getMyGroup = async (req, res, next) => { try { const myId = req.user.id; - const group = await StreakGroup.findOne({ members: myId }) - .populate('members', MEMBER_PUBLIC_FIELDS) - .populate('pendingInvites', 'pseudo level rank'); + // Groupe non populé d'abord : la détection de rupture de streak (Hall of + // Shame) a besoin des ObjectId bruts des membres, pas de documents User + // populés (String(userDoc) ne donne pas son _id). + let group = await StreakGroup.findOne({ members: myId }); // Invitations de groupe reçues (groupes où je suis en pendingInvites), // pour que l'invité puisse accepter/refuser depuis l'app. @@ -534,14 +688,22 @@ exports.getMyGroup = async (req, res, next) => { }); } + group = await detectAndApplyStreakBreak(group); + await group.populate([ + { path: 'members', select: MEMBER_PUBLIC_FIELDS }, + { path: 'pendingInvites', select: 'pseudo level rank' }, + { path: 'shameBreakers', select: 'pseudo' }, + ]); + // Multiplicateur courant (taille + régularité), affichable même sans // attendre la prochaine validation — group est un document Mongoose, // on construit la réponse à part pour ne pas le muter. const xpBonus = computeGroupXpBonus(group.members.length, group.currentStreak); + const membersWithWeather = await attachWeatherStatuses(group.members); return res.status(200).json({ success: true, - group: { ...group.toObject(), xpBonus }, + group: { ...group.toObject(), members: membersWithWeather, xpBonus }, invites, }); } catch (err) { diff --git a/back/controllers/inventory.controller.js b/back/controllers/inventory.controller.js index 5d7b08b..72ffb8b 100644 --- a/back/controllers/inventory.controller.js +++ b/back/controllers/inventory.controller.js @@ -5,6 +5,7 @@ const { drawChestItem } = require('../services/chest.service'); const { consumeItemAtomic, addItemAtomic, addUniqueItemOnce, purgeEmptyEntries } = require('../services/inventory.service'); const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); const { checkAndUnlockAchievements } = require('./reward.controller'); +const { recordActivityEvent } = require('../services/activity.service'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -140,6 +141,19 @@ exports.openChest = async (req, res, next) => { const drawnItem = drawChestItem(); await addItemAtomic(req.user.id, drawnItem.itemType, drawnItem.rarity); + // Flux d'activité (Section IV) : un coffre Légendaire mérite d'être + // annoncé au groupe — silencieux si l'utilisateur n'a pas de groupe. + if (drawnItem.rarity === 'legendary') { + const opener = await User.findById(req.user.id).select('pseudo'); + const pseudo = opener?.pseudo ?? 'Un membre'; + await recordActivityEvent( + req.user.id, + 'chest_legendary', + `${pseudo} a ouvert un coffre Légendaire !`, + { itemType: drawnItem.itemType }, + ); + } + // Compteur à vie — indépendant de l'inventaire courant (purgé plus bas). const afterCount = await User.findOneAndUpdate( { _id: req.user.id }, diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index a21d874..f2e6d08 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -86,4 +86,20 @@ exports.updateFrame = async (req, res, next) => { } catch (error) { next(error); } +}; + +/** + * ENREGISTRER LE TOKEN PUSH + * Permet aux notifications de groupe (Secouer, réactions du flux d'activité…) + * d'atteindre réellement l'appareil de l'utilisateur. + */ +exports.registerPushToken = async (req, res, next) => { + try { + const { pushToken } = req.body; + await userService.registerPushToken(req.user.id, pushToken); + + res.status(200).json({ success: true, message: "Token push enregistré." }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/back/models/ActivityEvent.js b/back/models/ActivityEvent.js new file mode 100644 index 0000000..73f34f8 --- /dev/null +++ b/back/models/ActivityEvent.js @@ -0,0 +1,45 @@ +const mongoose = require("mongoose"); + +// ----------------------------------------------------- +// Modèle "ActivityEvent" +// ----------------------------------------------------- +// Flux d'activité "Taquineries & High-Fives" (Section IV) : événements +// notables d'un membre de groupe (PR battu, coffre légendaire ouvert...), +// affichés aux autres membres du groupe au lancement de l'app avec des +// boutons de réaction à punchlines. +// ----------------------------------------------------- + +const ReactionSchema = new mongoose.Schema( + { + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + emoji: { type: String, enum: ["bravo", "respect", "boo", "jealous"], required: true }, + }, + { _id: false, timestamps: { createdAt: true, updatedAt: false } } +); + +const ActivityEventSchema = new mongoose.Schema( + { + group: { type: mongoose.Schema.Types.ObjectId, ref: "StreakGroup", required: true }, + actor: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + + // Type d'événement déclencheur (extensible sans migration : ajouter un + // type = ajouter un cas dans le hook + le libellé front, rien d'autre). + type: { type: String, enum: ["pr_broken", "chest_legendary"], required: true }, + + // Détails bruts (nom d'exercice + poids, ou rareté du coffre) — permet au + // front de reconstruire un libellé sans dépendre uniquement de `message`. + payload: { type: mongoose.Schema.Types.Mixed, default: {} }, + + // Libellé français prêt à afficher, calculé une fois à la création + // (ex: "Léo a brisé son record au Squat !"). + message: { type: String, required: true }, + + // Une réaction par utilisateur maximum (upsert géré côté service). + reactions: { type: [ReactionSchema], default: [] }, + }, + { timestamps: true } +); + +ActivityEventSchema.index({ group: 1, createdAt: -1 }); + +module.exports = mongoose.model("ActivityEvent", ActivityEventSchema); diff --git a/back/models/StreakGroup.js b/back/models/StreakGroup.js index 5d82241..84ce421 100644 --- a/back/models/StreakGroup.js +++ b/back/models/StreakGroup.js @@ -37,6 +37,12 @@ const StreakGroupSchema = new mongoose.Schema( // Sang Unique"). Ne redescend jamais, même si la streak est ensuite // remise à 0 — évite un ré-octroi si le groupe rebâtit une streak. bloodSangAwarded: { type: Boolean, default: false }, + + // Hall of Shame (Section IV) : membre(s) responsables de la dernière + // rupture de streak collective détectée (voir detectAndApplyStreakBreak + // dans groupStreak.controller.js). Vidé dès qu'une nouvelle streak est + // validée avec succès — reste affiché jusque-là. + shameBreakers: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], }, { timestamps: true } ); diff --git a/back/models/User.js b/back/models/User.js index 7d76f25..be15d2f 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -131,6 +131,21 @@ const UserSchema = new mongoose.Schema( // ex. "FRAME_SHAPE_DRAGONFANG", "FRAME_COLOR_BLOODSANG", "THEME_BLOODSANG". unlockedCosmetics: { type: [String], default: [] }, + // ── Présence & notifications push ───────────────────────────────────────── + // Token Expo Push (ExponentPushToken[...]) enregistré par le front après + // acceptation des permissions — voir push.service.js. null = aucun appareil + // enregistré (l'envoi est alors silencieusement ignoré, jamais une erreur). + pushToken: { type: String, default: null }, + + // Dernière activité connue (requête authentifiée quelconque) — alimente le + // statut "Prêt" de la Météo des séances (voir groupStreak.controller.js). + lastActiveAt: { type: Date, default: null }, + + // Dernière consultation du flux d'activité "Taquineries & High-Fives" — + // sert de curseur pour ne remonter que les événements nouveaux au + // lancement de l'app (voir activity.controller.js). + lastActivityFeedCheckAt: { type: Date, default: null }, + // ── Parrainage V2 ───────────────────────────────────────────────────────── // Code unique généré à la création du compte (ex: "ATH-X7K2P") referralCode: { type: String, unique: true, sparse: true }, diff --git a/back/package-lock.json b/back/package-lock.json index b2ef792..107c467 100644 --- a/back/package-lock.json +++ b/back/package-lock.json @@ -12,6 +12,7 @@ "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^17.2.3", + "expo-server-sdk": "^6.1.0", "express": "^5.2.1", "express-rate-limit": "^8.5.2", "express-validator": "^7.2.1", @@ -3139,6 +3140,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3478,6 +3485,20 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/expo-server-sdk": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-6.1.0.tgz", + "integrity": "sha512-ISuax1AQ7cpM5RAqcu8gVcoLL0ZKskJ5OLoMWmdITBe9nYjTucjdGyBq817YkIvTcj1pAUwx+9toUT7l/V7thA==", + "license": "MIT", + "dependencies": { + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1", + "undici": "^7.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -6485,6 +6506,25 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6683,6 +6723,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -7556,6 +7605,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/back/package.json b/back/package.json index 5965fb0..8bf0e65 100644 --- a/back/package.json +++ b/back/package.json @@ -23,6 +23,7 @@ "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^17.2.3", + "expo-server-sdk": "^6.1.0", "express": "^5.2.1", "express-rate-limit": "^8.5.2", "express-validator": "^7.2.1", diff --git a/back/routes/activity.routes.js b/back/routes/activity.routes.js new file mode 100644 index 0000000..4e2327a --- /dev/null +++ b/back/routes/activity.routes.js @@ -0,0 +1,15 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth.middleware'); +const validate = require('../middleware/validate.middleware'); +const activity = require('../controllers/activity.controller'); +const { react } = require('../validators/activity.validator'); + +router.use(auth); + +router.get('/feed', activity.getFeed); +router.post('/:eventId/react', validate(react), activity.react); + +module.exports = router; diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index abe8dc1..8a2dc29 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const userController = require("../controllers/user.controller"); const auth = require("../middleware/auth.middleware"); const validate = require("../middleware/validate.middleware"); -const { updateProfile, updateFrame, updateShowcase } = require("../validators/user.validator"); +const { updateProfile, updateFrame, updateShowcase, registerPushToken } = require("../validators/user.validator"); /** * ROUTES UTILISATEURS PROTEGEES @@ -22,6 +22,9 @@ router.put("/me/frame", auth, validate(updateFrame), userController.updateFrame) // Mettre à jour la vitrine de trophées mis en avant (max 3) router.put("/me/showcase", auth, validate(updateShowcase), userController.updateShowcase); +// Enregistrer (ou effacer) le token Expo Push de l'appareil courant +router.put("/me/push-token", auth, validate(registerPushToken), userController.registerPushToken); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/activity.service.js b/back/services/activity.service.js new file mode 100644 index 0000000..21e87e6 --- /dev/null +++ b/back/services/activity.service.js @@ -0,0 +1,80 @@ +'use strict'; + +const ActivityEvent = require('../models/ActivityEvent'); +const StreakGroup = require('../models/StreakGroup'); + +// Punchlines associées à chaque bouton de réaction (Section IV — flux +// "Taquineries & High-Fives"). Clé stable stockée en DB, libellé affiché +// uniquement côté service pour rester la seule source de vérité. +const REACTION_META = { + bravo: { emoji: '💪', label: 'Bien joué mec !' }, + respect: { emoji: '🔥', label: 'Respect !' }, + boo: { emoji: '👎', label: 'Bouuuuuh !' }, + jealous: { emoji: '👀', label: 'Jalouser' }, +}; + +/** + * Crée un ActivityEvent pour le groupe de `actorId`, si celui-ci appartient + * à un groupe. Silencieux si aucun groupe (pas de feed sans groupe) — ne + * doit jamais faire échouer l'action qui l'a déclenché (PR, coffre...). + */ +async function recordActivityEvent(actorId, type, message, payload = {}) { + try { + const group = await StreakGroup.findOne({ members: actorId }).select('_id'); + if (!group) return null; + + return await ActivityEvent.create({ group: group._id, actor: actorId, type, message, payload }); + } catch (err) { + console.warn(`⚠️ [ACTIVITY] Échec de création d'événement (${type}) :`, err.message); + return null; + } +} + +/** + * Événements non encore vus par `userId` dans son groupe, depuis son dernier + * check (`lastActivityFeedCheckAt`). Exclut les événements dont l'utilisateur + * est lui-même l'auteur (on ne s'auto-taquine pas). + */ +async function getUnseenFeedForUser(userId, since) { + const group = await StreakGroup.findOne({ members: userId }).select('_id'); + if (!group) return []; + + const query = { group: group._id, actor: { $ne: userId } }; + if (since) query.createdAt = { $gt: since }; + + return ActivityEvent.find(query) + .sort({ createdAt: -1 }) + .limit(20) + .populate('actor', 'pseudo level rank') + .populate('reactions.user', 'pseudo'); +} + +/** + * Ajoute/remplace la réaction de `reactorId` sur un événement (une réaction + * par utilisateur — un second clic change simplement l'émotion choisie). + * Retourne l'ID de l'auteur de l'événement (pour notifier), ou null si + * l'événement n'existe pas ou si on réagit à son propre événement. + */ +async function reactToEvent(eventId, reactorId, emoji) { + if (!REACTION_META[emoji]) { + const err = new Error('Réaction invalide.'); + err.statusCode = 400; + throw err; + } + + const event = await ActivityEvent.findById(eventId); + if (!event) { + const err = new Error('Événement introuvable.'); + err.statusCode = 404; + throw err; + } + + event.reactions = event.reactions.filter((r) => String(r.user) !== String(reactorId)); + event.reactions.push({ user: reactorId, emoji }); + await event.save(); + + if (String(event.actor) === String(reactorId)) return { event, notifyActorId: null }; + return { event, notifyActorId: event.actor }; +} + +module.exports = { REACTION_META, recordActivityEvent, getUnseenFeedForUser, reactToEvent }; diff --git a/back/services/exercise.service.js b/back/services/exercise.service.js index b9a8f1c..40c6934 100644 --- a/back/services/exercise.service.js +++ b/back/services/exercise.service.js @@ -1,4 +1,7 @@ +const mongoose = require("mongoose"); const ExerciseRecord = require("../models/ExerciseRecord"); +const User = require("../models/User"); +const { recordActivityEvent } = require("./activity.service"); /** * Service gérant les performances (séries, reps, poids). @@ -6,16 +9,47 @@ const ExerciseRecord = require("../models/ExerciseRecord"); class ExerciseService { /** * Enregistre une performance pour un exercice. + * + * Détection de PR (Section IV — flux d'activité) : compare le poids max + * déjà enregistré par l'utilisateur sur cet exercice à celui de la + * nouvelle performance. Si dépassé, publie un ActivityEvent "pr_broken" + * visible par le groupe de streak (silencieux si l'utilisateur n'a pas + * de groupe). */ async createRecord(userId, recordData) { + const previousMax = await this.getMaxWeightForExercise(userId, recordData.exerciceNom); + // Correction de la typo : exerciceNom selon ton modèle const record = await ExerciseRecord.create({ ...recordData, user: userId, }); + + const newMax = Math.max(0, ...(record.series || []).map((s) => s.poids || 0)); + if (newMax > previousMax) { + const user = await User.findById(userId).select('pseudo'); + const pseudo = user?.pseudo ?? 'Un membre'; + await recordActivityEvent( + userId, + 'pr_broken', + `${pseudo} a brisé son record au ${record.exerciceNom} !`, + { exercise: record.exerciceNom, weight: newMax }, + ); + } + return record; } + /** Poids max historique déjà enregistré par l'utilisateur sur cet exercice. */ + async getMaxWeightForExercise(userId, exerciceNom) { + const rows = await ExerciseRecord.aggregate([ + { $match: { user: new mongoose.Types.ObjectId(userId), exerciceNom } }, + { $unwind: '$series' }, + { $group: { _id: null, maxPoids: { $max: '$series.poids' } } }, + ]); + return rows[0]?.maxPoids || 0; + } + /** * Récupère l'historique complet d'un exercice précis pour voir la progression. */ diff --git a/back/services/push.service.js b/back/services/push.service.js new file mode 100644 index 0000000..0319e22 --- /dev/null +++ b/back/services/push.service.js @@ -0,0 +1,82 @@ +'use strict'; + +const User = require('../models/User'); + +// ─── Service d'envoi de notifications push (Expo) ───────────────────────────── +// Seul point du backend qui parle réellement à un appareil. Tout le reste de +// l'app (shakeMember, réactions du flux d'activité…) passe par sendPushToUser +// plutôt que d'appeler expo-server-sdk directement — un seul endroit à changer +// si le fournisseur de push évolue un jour. +// +// Conçu pour ne JAMAIS faire échouer l'appelant : un push est un bonus UX, pas +// une opération critique. Toute erreur (token absent, invalide, Expo down) est +// avalée et loguée — les fonctions retournent simplement `false`. +// +// expo-server-sdk est distribué en pur ESM ("type": "module", pas de build +// CJS) alors que tout ce backend est en CommonJS : on ne peut pas le +// `require()` directement (SyntaxError au chargement). Un `import()` +// dynamique fonctionne depuis du code CJS et est mis en cache par le moteur +// après le premier appel — le coût de chargement n'est payé qu'une fois. +let expoModulePromise = null; +async function getExpoModule() { + if (!expoModulePromise) { + expoModulePromise = import('expo-server-sdk'); + } + return expoModulePromise; +} + +/** + * Envoie une notification push à UN utilisateur via son pushToken enregistré. + * No-op silencieux si l'utilisateur n'a pas de token (jamais ouvert l'app côté + * notifications, ou les a refusées). + * + * @param {string} userId + * @param {{ title: string, body: string, data?: object }} message + * @returns {Promise} true si un ticket d'envoi a été obtenu + */ +async function sendPushToUser(userId, { title, body, data = {} }) { + try { + const { Expo } = await getExpoModule(); + const expo = new Expo(); + const user = await User.findById(userId).select('pushToken'); + if (!user || !user.pushToken || !Expo.isExpoPushToken(user.pushToken)) { + return false; + } + + const [ticket] = await expo.sendPushNotificationsAsync([{ + to: user.pushToken, + sound: 'default', + title, + body, + data, + }]); + + if (ticket?.status === 'error') { + console.warn(`⚠️ [PUSH] Échec d'envoi à ${userId} :`, ticket.message, ticket.details); + // Token périmé/désinstallé : on le retire pour ne plus retenter dans le vide. + if (ticket.details?.error === 'DeviceNotRegistered') { + await User.updateOne({ _id: userId }, { $set: { pushToken: null } }); + } + return false; + } + + return true; + } catch (err) { + console.warn(`⚠️ [PUSH] Erreur d'envoi à ${userId} :`, err.message); + return false; + } +} + +/** + * Envoie la même notification à plusieurs utilisateurs (best-effort, en + * parallèle). Utile pour les événements de groupe (ex: annonce à toute + * l'équipe). Ne lève jamais — chaque envoi individuel gère déjà ses erreurs. + * + * @param {string[]} userIds + * @param {{ title: string, body: string, data?: object }} message + */ +async function sendPushToUsers(userIds, message) { + await Promise.all(userIds.map((id) => sendPushToUser(id, message))); +} + +module.exports = { sendPushToUser, sendPushToUsers }; diff --git a/back/services/user.service.js b/back/services/user.service.js index b3ae31c..9b1ea14 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -24,6 +24,12 @@ class UserService { await user.save(); } + // Présence : getMe est appelé à chaque focus d'écran côté front, ce qui + // en fait un signal "activité récente" fiable pour la Météo des séances + // (statut 🔥 Prêt) sans avoir besoin d'un endpoint de heartbeat dédié. + // Fire-and-forget : ne doit jamais retarder ni faire échouer la réponse. + User.updateOne({ _id: userId }, { $set: { lastActiveAt: new Date() } }).catch(() => {}); + return user; } @@ -100,6 +106,21 @@ class UserService { return updatedUser; } + /** + * Enregistre (ou efface, si null) le token Expo Push de l'appareil courant. + * @param {string} userId + * @param {string|null} pushToken + */ + async registerPushToken(userId, pushToken) { + const updatedUser = await User.findByIdAndUpdate( + userId, + { $set: { pushToken } }, + { new: true }, + ).select('pushToken'); + if (!updatedUser) throw new Error('Utilisateur non trouvé.'); + return updatedUser; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/activityFeed.test.js b/back/tests/activityFeed.test.js new file mode 100644 index 0000000..f4ad4e6 --- /dev/null +++ b/back/tests/activityFeed.test.js @@ -0,0 +1,224 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const Friendship = require('../models/Friendship'); +const StreakGroup = require('../models/StreakGroup'); +const Workout = require('../models/Workout'); +const ActivityEvent = require('../models/ActivityEvent'); + +async function createAndLoginUser(pseudo, email) { + await request(app).post('/api/auth/register').send({ pseudo, email, password: 'Password123!' }); + await User.updateOne({ email }, { isVerified: true }); + const loginRes = await request(app).post('/api/auth/login').send({ email, password: 'Password123!' }); + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +describe("Flux d'activité « Taquineries & High-Fives » — Section IV", () => { + let alice, bob; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + await User.deleteMany({}); + await Friendship.deleteMany({}); + await StreakGroup.deleteMany({}); + await Workout.deleteMany({}); + await ActivityEvent.deleteMany({}); + + alice = await createAndLoginUser('AliceActivity', 'alice.activity@athly.fr'); + bob = await createAndLoginUser('BobActivity', 'bob.activity@athly.fr'); + + await Friendship.create({ requester: alice.userId, recipient: bob.userId, status: 'accepted' }); + await StreakGroup.create({ members: [alice.userId, bob.userId] }); + }); + + afterAll(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + await StreakGroup.deleteMany({}); + await Workout.deleteMany({}); + await ActivityEvent.deleteMany({}); + await mongoose.connection.close(); + }); + + afterEach(async () => { + await ActivityEvent.deleteMany({}); + await Workout.deleteMany({}); + }); + + describe('Détection de PR sur POST /api/exercises/', () => { + it("✅ Publie un événement pr_broken quand un nouveau poids max est atteint", async () => { + const workoutRes = await request(app) + .post('/api/workouts') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titre: 'Séance PR', categorie: 'Push' }); + const workoutId = workoutRes.body.workout._id; + + await request(app) + .post('/api/exercises/') + .set('Authorization', `Bearer ${alice.token}`) + .send({ workout: workoutId, exerciceNom: 'Squat', series: [{ repetitions: 5, poids: 100 }] }); + + const events = await ActivityEvent.find({ type: 'pr_broken' }); + expect(events.length).toBe(1); + expect(events[0].message).toContain('Squat'); + expect(events[0].payload.weight).toBe(100); + }); + + it("❌ Ne publie rien si le poids n'excède pas le max déjà enregistré", async () => { + const workoutRes = await request(app) + .post('/api/workouts') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titre: 'Séance 1', categorie: 'Push' }); + const w1 = workoutRes.body.workout._id; + + await request(app) + .post('/api/exercises/') + .set('Authorization', `Bearer ${alice.token}`) + .send({ workout: w1, exerciceNom: 'Développé Couché', series: [{ repetitions: 5, poids: 80 }] }); + + const workout2Res = await request(app) + .post('/api/workouts') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titre: 'Séance 2', categorie: 'Push' }); + const w2 = workout2Res.body.workout._id; + + await request(app) + .post('/api/exercises/') + .set('Authorization', `Bearer ${alice.token}`) + .send({ workout: w2, exerciceNom: 'Développé Couché', series: [{ repetitions: 5, poids: 70 }] }); + + const events = await ActivityEvent.find({ type: 'pr_broken', 'payload.exercise': 'Développé Couché' }); + expect(events.length).toBe(1); // uniquement la première séance (80kg) + }); + }); + + describe('GET /api/activity/feed & POST /api/activity/:eventId/react', () => { + it("✅ Bob voit le PR d'Alice dans son feed, mais pas Alice elle-même", async () => { + const workoutRes = await request(app) + .post('/api/workouts') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titre: 'Séance', categorie: 'Push' }); + const workoutId = workoutRes.body.workout._id; + + await request(app) + .post('/api/exercises/') + .set('Authorization', `Bearer ${alice.token}`) + .send({ workout: workoutId, exerciceNom: 'Soulevé de Terre', series: [{ repetitions: 3, poids: 150 }] }); + + const bobFeed = await request(app) + .get('/api/activity/feed') + .set('Authorization', `Bearer ${bob.token}`); + expect(bobFeed.statusCode).toBe(200); + expect(bobFeed.body.events.length).toBe(1); + expect(bobFeed.body.events[0].actor.pseudo).toBe('AliceActivity'); + + const aliceFeed = await request(app) + .get('/api/activity/feed') + .set('Authorization', `Bearer ${alice.token}`); + expect(aliceFeed.body.events.length).toBe(0); + }); + + it("✅ Le feed ne renvoie pas deux fois le même événement (curseur lastActivityFeedCheckAt)", async () => { + const workoutRes = await request(app) + .post('/api/workouts') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titre: 'Séance', categorie: 'Push' }); + const workoutId = workoutRes.body.workout._id; + + await request(app) + .post('/api/exercises/') + .set('Authorization', `Bearer ${alice.token}`) + .send({ workout: workoutId, exerciceNom: 'Overhead Press', series: [{ repetitions: 5, poids: 40 }] }); + + const firstFeed = await request(app).get('/api/activity/feed').set('Authorization', `Bearer ${bob.token}`); + expect(firstFeed.body.events.length).toBe(1); + + const secondFeed = await request(app).get('/api/activity/feed').set('Authorization', `Bearer ${bob.token}`); + expect(secondFeed.body.events.length).toBe(0); + }); + + it("✅ Bob réagit à l'événement d'Alice avec 'respect'", async () => { + const workoutRes = await request(app) + .post('/api/workouts') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titre: 'Séance', categorie: 'Push' }); + const workoutId = workoutRes.body.workout._id; + + await request(app) + .post('/api/exercises/') + .set('Authorization', `Bearer ${alice.token}`) + .send({ workout: workoutId, exerciceNom: 'Traction', series: [{ repetitions: 8, poids: 20 }] }); + + const event = await ActivityEvent.findOne({ type: 'pr_broken', 'payload.exercise': 'Traction' }); + + const reactRes = await request(app) + .post(`/api/activity/${event._id}/react`) + .set('Authorization', `Bearer ${bob.token}`) + .send({ emoji: 'respect' }); + + expect(reactRes.statusCode).toBe(200); + expect(reactRes.body.reactions.length).toBe(1); + expect(reactRes.body.reactions[0].emoji).toBe('respect'); + }); + + it("❌ 400 si l'émoji de réaction n'est pas dans la liste autorisée", async () => { + const workoutRes = await request(app) + .post('/api/workouts') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titre: 'Séance', categorie: 'Push' }); + const workoutId = workoutRes.body.workout._id; + + await request(app) + .post('/api/exercises/') + .set('Authorization', `Bearer ${alice.token}`) + .send({ workout: workoutId, exerciceNom: 'Fentes', series: [{ repetitions: 8, poids: 25 }] }); + + const event = await ActivityEvent.findOne({ type: 'pr_broken', 'payload.exercise': 'Fentes' }); + + const reactRes = await request(app) + .post(`/api/activity/${event._id}/react`) + .set('Authorization', `Bearer ${bob.token}`) + .send({ emoji: 'not_a_real_emoji' }); + + expect(reactRes.statusCode).toBe(400); + }); + + it("❌ Refusé avec 401 sans token", async () => { + const res = await request(app).get('/api/activity/feed'); + expect(res.statusCode).toBe(401); + }); + }); + + describe('Coffre Légendaire — annonce au groupe', () => { + it("✅ Publie un événement chest_legendary quand un coffre légendaire est tiré", async () => { + // La rareté légendaire n'a que 3% de chance par tirage (chest.service.js) : + // on ouvre suffisamment de coffres pour en obtenir au moins un avec une + // probabilité d'échec négligeable (~1 - 0.97^300 ≈ 99.999%), plutôt que + // de mocker le tirage — inventory.controller.js déstructure + // `drawChestItem` à l'import, un spy sur le module ne l'atteindrait pas. + await User.updateOne( + { _id: alice.userId }, + { $set: { level: 11, inventory: [{ itemType: 'CHEST_KEY', rarity: 'common', quantity: 300 }] } }, + ); + + let legendaryEventFound = false; + for (let i = 0; i < 300 && !legendaryEventFound; i++) { + await request(app) + .post('/api/inventory/chest/open') + .set('Authorization', `Bearer ${alice.token}`); + + legendaryEventFound = Boolean(await ActivityEvent.findOne({ type: 'chest_legendary' })); + } + + expect(legendaryEventFound).toBe(true); + const event = await ActivityEvent.findOne({ type: 'chest_legendary' }); + expect(event.message).toContain('Légendaire'); + }, 30000); + }); +}); diff --git a/back/tests/groupStreak.test.js b/back/tests/groupStreak.test.js index 878de55..5708d78 100644 --- a/back/tests/groupStreak.test.js +++ b/back/tests/groupStreak.test.js @@ -452,4 +452,190 @@ describe("Streaks de Groupe & Niveaux d'Amitié Athly — V2", () => { expect(res.statusCode).toBe(401); }); }); + + // ─────────────────────────────────────────────────────────────────────────── + // 6. Météo des séances — weatherStatus par membre dans getMyGroup + // ─────────────────────────────────────────────────────────────────────────── + describe('GET /api/groups/my-group — Météo des séances (weatherStatus)', () => { + afterEach(async () => { + await User.updateMany({}, { $set: { lastActiveAt: null } }); + }); + + it("✅ done : membre avec une séance 'finished' aujourd'hui", async () => { + await StreakGroup.create({ members: [alice.userId, bob.userId] }); + await Workout.create({ user: alice.userId, status: 'finished', date: new Date() }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + const aliceMember = res.body.group.members.find((m) => m._id === alice.userId); + expect(aliceMember.weatherStatus).toBe('done'); + }); + + it("✅ active : membre avec une séance 'in_progress' mise à jour il y a moins de 4h", async () => { + await StreakGroup.create({ members: [alice.userId, bob.userId] }); + await Workout.create({ user: bob.userId, status: 'in_progress', date: new Date() }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + const bobMember = res.body.group.members.find((m) => m._id === bob.userId); + expect(bobMember.weatherStatus).toBe('active'); + }); + + it("✅ sleeping : séance 'in_progress' du jour mais oubliée depuis plus de 4h", async () => { + await StreakGroup.create({ members: [alice.userId, bob.userId] }); + const stale = await Workout.create({ user: bob.userId, status: 'in_progress', date: new Date() }); + await Workout.updateOne( + { _id: stale._id }, + { $set: { updatedAt: new Date(Date.now() - 5 * 60 * 60 * 1000) } }, + { timestamps: false }, + ); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + const bobMember = res.body.group.members.find((m) => m._id === bob.userId); + expect(bobMember.weatherStatus).toBe('sleeping'); + }); + + it("✅ ready : aucune séance du jour mais lastActiveAt aujourd'hui", async () => { + await StreakGroup.create({ members: [alice.userId, bob.userId] }); + await User.updateOne({ _id: bob.userId }, { $set: { lastActiveAt: new Date() } }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + const bobMember = res.body.group.members.find((m) => m._id === bob.userId); + expect(bobMember.weatherStatus).toBe('ready'); + }); + + it("✅ sleeping : aucun signal d'activité aujourd'hui", async () => { + await StreakGroup.create({ members: [alice.userId, bob.userId] }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + const bobMember = res.body.group.members.find((m) => m._id === bob.userId); + expect(bobMember.weatherStatus).toBe('sleeping'); + }); + + it("✅ done l'emporte sur active si les deux signaux sont présents", async () => { + await StreakGroup.create({ members: [alice.userId, bob.userId] }); + await Workout.create({ user: alice.userId, status: 'in_progress', date: new Date() }); + await Workout.create({ user: alice.userId, status: 'finished', date: new Date() }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + const aliceMember = res.body.group.members.find((m) => m._id === alice.userId); + expect(aliceMember.weatherStatus).toBe('done'); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 7. Hall of Shame — détection paresseuse de rupture de streak + // ─────────────────────────────────────────────────────────────────────────── + describe('GET /api/groups/my-group — Hall of Shame (rupture de streak)', () => { + afterEach(async () => { + await User.updateMany({}, { $set: { streakGels: 0 } }); + }); + + it("✅ Reset à 0 et Bob désigné briseur s'il n'a pas validé hier et n'a aucun Gel", async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const group = await StreakGroup.create({ + members: [alice.userId, bob.userId], + currentStreak: 5, + lastValidatedDate: new Date(yesterday.getTime() - 24 * 60 * 60 * 1000), // avant-hier + }); + // Alice a validé hier, Bob non, et Bob n'a aucun gel + await Workout.create({ user: alice.userId, status: 'finished', date: yesterday }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.group.currentStreak).toBe(0); + expect(res.body.group.shameBreakers.map((b) => b._id)).toEqual([bob.userId]); + + const updated = await StreakGroup.findById(group._id); + expect(updated.currentStreak).toBe(0); + expect(updated.shameBreakers.map(String)).toEqual([bob.userId]); + }); + + it("✅ Streak préservée si le membre absent avait un Gel de Streak (consommé)", async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + await User.updateOne({ _id: bob.userId }, { $set: { streakGels: 2 } }); + + const group = await StreakGroup.create({ + members: [alice.userId, bob.userId], + currentStreak: 5, + lastValidatedDate: new Date(yesterday.getTime() - 24 * 60 * 60 * 1000), + }); + await Workout.create({ user: alice.userId, status: 'finished', date: yesterday }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.group.currentStreak).toBe(5); + expect(res.body.group.shameBreakers).toEqual([]); + + const bobAfter = await User.findById(bob.userId).select('streakGels'); + expect(bobAfter.streakGels).toBe(1); // 1 gel consommé automatiquement + + const updated = await StreakGroup.findById(group._id); + expect(updated.currentStreak).toBe(5); + }); + + it("✅ Le bandeau est vidé dès qu'une nouvelle streak est validée avec succès", async () => { + const group = await StreakGroup.create({ + members: [alice.userId, bob.userId], + currentStreak: 0, + shameBreakers: [bob.userId], + }); + + const today = new Date(); + await Workout.create({ user: alice.userId, status: 'finished', date: today }); + await Workout.create({ user: bob.userId, status: 'finished', date: today }); + + await request(app) + .post(`/api/groups/${group._id}/check-streak`) + .set('Authorization', `Bearer ${alice.token}`); + + const updated = await StreakGroup.findById(group._id); + expect(updated.shameBreakers).toEqual([]); + expect(updated.currentStreak).toBe(1); + }); + + it("✅ Aucune rupture détectée si tout le monde avait validé la veille (juste pas cliqué)", async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + await StreakGroup.create({ + members: [alice.userId, bob.userId], + currentStreak: 5, + lastValidatedDate: new Date(yesterday.getTime() - 24 * 60 * 60 * 1000), + }); + await Workout.create({ user: alice.userId, status: 'finished', date: yesterday }); + await Workout.create({ user: bob.userId, status: 'finished', date: yesterday }); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.group.currentStreak).toBe(5); + expect(res.body.group.shameBreakers).toEqual([]); + }); + }); }); diff --git a/back/validators/activity.validator.js b/back/validators/activity.validator.js new file mode 100644 index 0000000..8ae1049 --- /dev/null +++ b/back/validators/activity.validator.js @@ -0,0 +1,9 @@ +const Joi = require('joi'); + +const activitySchemas = { + react: Joi.object({ + emoji: Joi.string().valid('bravo', 'respect', 'boo', 'jealous').required(), + }), +}; + +module.exports = activitySchemas; diff --git a/back/validators/user.validator.js b/back/validators/user.validator.js index d253ddd..6982739 100644 --- a/back/validators/user.validator.js +++ b/back/validators/user.validator.js @@ -26,6 +26,11 @@ const userSchemas = { updateShowcase: Joi.object({ achievementIds: Joi.array().items(Joi.string().max(60)).max(3).required(), }), + + registerPushToken: Joi.object({ + // null explicite = désenregistrement (permissions révoquées côté client) + pushToken: Joi.string().max(200).allow(null).required(), + }), }; module.exports = userSchemas; \ No newline at end of file diff --git a/front/src/components/cards/ExerciseCard.js b/front/src/components/cards/ExerciseCard.js index 6d16d9b..ce63d0b 100644 --- a/front/src/components/cards/ExerciseCard.js +++ b/front/src/components/cards/ExerciseCard.js @@ -85,7 +85,7 @@ function ExerciseCard({ > - {icon} + diff --git a/front/src/components/common/ErrorBoundary.js b/front/src/components/common/ErrorBoundary.js index 482a291..783f998 100644 --- a/front/src/components/common/ErrorBoundary.js +++ b/front/src/components/common/ErrorBoundary.js @@ -1,5 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; // ─── ErrorBoundary ──────────────────────────────────────────────────────────── @@ -21,7 +22,7 @@ export default class ErrorBoundary extends React.Component { componentDidCatch(error, info) { // Log console uniquement — brancher un service de crash-reporting ici // (Sentry…) le jour où on en ajoute un. - console.error('💥 [ErrorBoundary]', error, info?.componentStack); + console.error('[ErrorBoundary]', error, info?.componentStack); } handleRetry = () => { @@ -39,7 +40,9 @@ export default class ErrorBoundary extends React.Component { return ( - 🏋️ + + + Oups, une erreur est survenue Pas de panique — tes données sont en sécurité.{'\n'}Réessaie, ça devrait repartir. @@ -60,7 +63,13 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 32, }, - emoji: { fontSize: 48, marginBottom: 18 }, + iconWrap: { + width: 76, height: 76, borderRadius: 22, + backgroundColor: `${Colors.error}18`, + borderWidth: 1, borderColor: `${Colors.error}45`, + justifyContent: 'center', alignItems: 'center', + marginBottom: 18, + }, title: { color: Colors.textPrimary, fontSize: 19, diff --git a/front/src/components/profile/BirthdayModal.js b/front/src/components/profile/BirthdayModal.js index 7257019..120b304 100644 --- a/front/src/components/profile/BirthdayModal.js +++ b/front/src/components/profile/BirthdayModal.js @@ -22,11 +22,11 @@ export default function BirthdayModal({ visible, pseudo, rewarded, onClose }) { - 🎂 + - Joyeux Anniversaire{pseudo ? ` ${pseudo}` : ''} ! 🎉 + Joyeux Anniversaire{pseudo ? ` ${pseudo}` : ''} ! @@ -90,7 +90,6 @@ const styles = StyleSheet.create({ alignItems: 'center', marginBottom: 18, }, - iconEmoji: { fontSize: 34 }, title: { color: Colors.textPrimary, fontSize: 19, diff --git a/front/src/components/social/ActivityFeedModal.js b/front/src/components/social/ActivityFeedModal.js new file mode 100644 index 0000000..ad45226 --- /dev/null +++ b/front/src/components/social/ActivityFeedModal.js @@ -0,0 +1,214 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, ScrollView } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import { useAuth } from '../../context/AuthContext'; +import { getActivityFeed, reactToActivityEvent } from '../../services/social.service'; + +// ─── ActivityFeedModal ───────────────────────────────────────────────────────── +// Flux d'activité « Taquineries & High-Fives » (Section IV) : au lancement de +// l'app, annonce les événements notables survenus dans le groupe de streak +// depuis la dernière consultation (PR battu, coffre Légendaire ouvert...), +// avec des boutons de réaction à punchlines. Se charge elle-même — il suffit +// de la monter une fois (voir navigation/index.js), au même niveau que +// BirthdayCelebration / LevelUpCelebration. +// +// Modale custom (jamais Alert.alert), cohérente avec ConfirmModal.js. + +const REACTION_META = { + bravo: { icon: 'barbell', label: 'Bien joué mec !' }, + respect: { icon: 'flame', label: 'Respect !' }, + boo: { icon: 'thumbs-down', label: 'Bouuuuuh !' }, + jealous: { icon: 'eye', label: 'Jalouser' }, +}; + +export default function ActivityFeedModal() { + const { userToken } = useAuth(); + const [events, setEvents] = useState([]); + const [visible, setVisible] = useState(false); + const [reactedIds, setReactedIds] = useState({}); + + useEffect(() => { + if (!userToken) return; + (async () => { + try { + const data = await getActivityFeed(); + if (Array.isArray(data.events) && data.events.length > 0) { + setEvents(data.events); + setVisible(true); + } + } catch (_) { + // Best-effort — un flux d'activité manquant ne doit jamais bloquer l'app. + } + })(); + }, [userToken]); + + const handleReact = useCallback(async (eventId, emoji) => { + setReactedIds((prev) => ({ ...prev, [eventId]: emoji })); + try { + await reactToActivityEvent(eventId, emoji); + } catch (_) { + setReactedIds((prev) => ({ ...prev, [eventId]: null })); + } + }, []); + + const handleClose = useCallback(() => setVisible(false), []); + + if (!visible) return null; + + return ( + + + + QUOI DE NEUF ? + + + Taquineries & High-Fives + + + + {events.map((event) => ( + handleReact(event._id, emoji)} + /> + ))} + + + + C'est noté + + + + + ); +} + +function ActivityEventRow({ event, reacted, onReact }) { + return ( + + {event.message} + + {Object.entries(REACTION_META).map(([key, meta]) => { + const isActive = reacted === key; + return ( + onReact(key)} + disabled={Boolean(reacted)} + activeOpacity={0.75} + > + + {meta.label} + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + maxHeight: '78%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + padding: 24, + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + eyebrow: { + color: Colors.textMuted, + fontSize: 11, + fontWeight: '800', + letterSpacing: 2, + marginBottom: 4, + textAlign: 'center', + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 7, + marginBottom: 18, + }, + title: { + color: Colors.textPrimary, + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.3, + textAlign: 'center', + }, + list: { maxHeight: 340 }, + eventRow: { + backgroundColor: Colors.cardDeep, + borderWidth: 1, + borderColor: Colors.borderSubtle, + borderRadius: 14, + padding: 14, + marginBottom: 12, + }, + eventMessage: { + color: Colors.textPrimary, + fontSize: 14, + fontWeight: '600', + lineHeight: 20, + marginBottom: 10, + }, + reactionsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + reactionBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, + borderColor: Colors.borderSubtle, + borderRadius: 10, + paddingVertical: 6, + paddingHorizontal: 9, + gap: 5, + }, + reactionBtnActive: { + backgroundColor: `${Colors.primary}22`, + borderColor: Colors.primary, + }, + reactionLabel: { color: Colors.textSecondary, fontSize: 11, fontWeight: '600' }, + closeBtn: { + width: '100%', + height: 48, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Colors.primary, + marginTop: 6, + shadowColor: Colors.primary, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + closeBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/components/social/FriendshipLevelUpModal.js b/front/src/components/social/FriendshipLevelUpModal.js index 64bc956..9663bc5 100644 --- a/front/src/components/social/FriendshipLevelUpModal.js +++ b/front/src/components/social/FriendshipLevelUpModal.js @@ -60,7 +60,7 @@ export default function FriendshipLevelUpModal({ visible, pseudo, level, onClose {isMax - ? "Niveau d'amitié maximum atteint ! Une récompense UNIQUE t'attend dans ton inventaire 🎁" + ? "Niveau d'amitié maximum atteint ! Une récompense UNIQUE t'attend dans ton inventaire." : 'Continuez à vous entraîner ensemble pour renforcer ce lien.'} diff --git a/front/src/components/web/DesktopInstallPage.js b/front/src/components/web/DesktopInstallPage.js index 1485fde..fe227b0 100644 --- a/front/src/components/web/DesktopInstallPage.js +++ b/front/src/components/web/DesktopInstallPage.js @@ -1,5 +1,6 @@ import React from 'react'; import { View, Text, Image, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; const LOGO = require('../../../assets/logo-orange.png'); @@ -18,7 +19,7 @@ function MethodCard({ icon, title, platform, steps, accent }) { return ( - {icon} + {title} {platform} @@ -56,7 +57,7 @@ export default function DesktopInstallPage() { - {icon} + diff --git a/front/src/components/workouts/InlineExerciseBlock.js b/front/src/components/workouts/InlineExerciseBlock.js index ec7aaf2..7fd93bd 100644 --- a/front/src/components/workouts/InlineExerciseBlock.js +++ b/front/src/components/workouts/InlineExerciseBlock.js @@ -90,7 +90,7 @@ function InlineExerciseBlock({ exercise, exerciseIndex, onRemoveExercise, onRepl delayLongPress={280} > - {icon} + diff --git a/front/src/components/workouts/MuscleHierarchyPicker.js b/front/src/components/workouts/MuscleHierarchyPicker.js index 5b23814..8ad90a7 100644 --- a/front/src/components/workouts/MuscleHierarchyPicker.js +++ b/front/src/components/workouts/MuscleHierarchyPicker.js @@ -64,7 +64,7 @@ function GroupRow({ group, expanded, onToggleExpand, mode, selected, onChange, e activeOpacity={0.85} > - {icon} + {group.label} diff --git a/front/src/constants/exerciseFilters.js b/front/src/constants/exerciseFilters.js index 624373c..040e2db 100644 --- a/front/src/constants/exerciseFilters.js +++ b/front/src/constants/exerciseFilters.js @@ -6,7 +6,7 @@ export const MUSCLE_GROUPS = [ { id: 'pectoraux', label: 'Pectoraux', - icon: '💪', + icon: 'barbell-outline', subMuscles: [ { id: 'pectoraux-haut', label: 'Pectoraux haut' }, { id: 'pectoraux-milieu', label: 'Pectoraux milieu' }, @@ -16,7 +16,7 @@ export const MUSCLE_GROUPS = [ { id: 'dos', label: 'Dos', - icon: '💪', + icon: 'barbell-outline', subMuscles: [ { id: 'grand-dorsal', label: 'Grand dorsal' }, { id: 'rhomboides', label: 'Rhomboïdes' }, @@ -27,7 +27,7 @@ export const MUSCLE_GROUPS = [ { id: 'epaules', label: 'Épaules', - icon: '💪', + icon: 'barbell-outline', subMuscles: [ { id: 'deltoide-anterieur', label: 'Deltoïde antérieur' }, { id: 'deltoide-lateral', label: 'Deltoïde latéral' }, @@ -37,7 +37,7 @@ export const MUSCLE_GROUPS = [ { id: 'bras', label: 'Bras', - icon: '💪', + icon: 'barbell-outline', subMuscles: [ { id: 'biceps', label: 'Biceps' }, { id: 'triceps', label: 'Triceps' }, @@ -47,7 +47,7 @@ export const MUSCLE_GROUPS = [ { id: 'jambes', label: 'Jambes', - icon: '🦵', + icon: 'walk-outline', subMuscles: [ { id: 'quadriceps', label: 'Quadriceps' }, { id: 'ischios', label: 'Ischios' }, @@ -59,7 +59,7 @@ export const MUSCLE_GROUPS = [ { id: 'abdos', label: 'Abdos', - icon: '💪', + icon: 'barbell-outline', subMuscles: [ { id: 'grand-droit', label: 'Grand droit' }, { id: 'obliques', label: 'Obliques' }, @@ -96,16 +96,17 @@ export const EQUIPMENTS = [ { id: 'machine', label: 'Machine' }, ]; -// Glyph affiché dans la card (cohérent avec la maquette : 💪 par défaut, 🔥 pour le poids du corps). +// Icône Ionicons affichée dans la card (barbell-outline par défaut, body-outline +// pour le poids du corps — pas de matériel, juste le corps). export const ICON_FOR_EQUIPMENT = { - 'poids-du-corps': '🔥', + 'poids-du-corps': 'body-outline', }; export const ICON_FOR_MUSCLE_GROUP = { - jambes: '🦵', + jambes: 'walk-outline', }; -export const DEFAULT_ICON = '💪'; +export const DEFAULT_ICON = 'barbell-outline'; // Normalise une chaîne libre (accents, casse) en id stable. export function normalizeId(value) { diff --git a/front/src/data/workoutTemplates.js b/front/src/data/workoutTemplates.js index 419f3e3..cf45fad 100644 --- a/front/src/data/workoutTemplates.js +++ b/front/src/data/workoutTemplates.js @@ -30,7 +30,7 @@ export const TEMPLATES = [ name: 'Séance Push', description: 'Pectoraux, épaules, triceps', musclesSummary: 'Pectoraux • Triceps • Épaules', - icon: '💪', + icon: 'barbell-outline', estimatedDurationMin: 75, buildExercises: () => [ buildExercise({ @@ -96,7 +96,7 @@ export const TEMPLATES = [ name: 'Séance Pull', description: 'Dos, biceps, trapèzes', musclesSummary: 'Dos • Biceps • Trapèzes', - icon: '💪', + icon: 'fitness-outline', estimatedDurationMin: 70, buildExercises: () => [ buildExercise({ @@ -155,7 +155,7 @@ export const TEMPLATES = [ name: 'Séance Jambes', description: 'Quadriceps, ischios, fessiers', musclesSummary: 'Quadriceps • Ischios • Fessiers', - icon: '🦵', + icon: 'walk-outline', estimatedDurationMin: 80, buildExercises: () => [ buildExercise({ @@ -207,7 +207,7 @@ export const TEMPLATES = [ name: 'Full body', description: 'Tout le corps en une séance', musclesSummary: 'Pectoraux • Dos • Jambes • Bras', - icon: '🔥', + icon: 'flame-outline', estimatedDurationMin: 60, buildExercises: () => [ buildExercise({ diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js index ae8df4e..184a698 100644 --- a/front/src/navigation/index.js +++ b/front/src/navigation/index.js @@ -18,9 +18,11 @@ import { QuestProvider } from '../context/QuestContext'; import { UserProvider } from '../context/UserContext'; import { TutorialProvider } from '../context/TutorialContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { setupNotificationChannels, ensureDailyRemindersScheduled } from '../services/notificationService'; +import { setupNotificationChannels, ensureDailyRemindersScheduled, getExpoPushToken } from '../services/notificationService'; +import { registerPushToken } from '../services/profile.service'; import BirthdayCelebration from '../components/profile/BirthdayCelebration'; import LevelUpCelebration from '../components/profile/LevelUpCelebration'; +import ActivityFeedModal from '../components/social/ActivityFeedModal'; const NOTIF_ENABLED_KEY = 'athly:notif:enabled:v1'; @@ -37,6 +39,20 @@ export default function AppNavigator() { })(); }, []); + // Enregistre le token Expo Push auprès du backend une fois connecté — c'est + // ce qui permet aux notifications d'un AUTRE appareil (Secouer, réactions + // du flux d'activité) d'atteindre réellement celui-ci. Best-effort : un + // échec (permission refusée, web, hors ligne) ne bloque jamais l'app. + useEffect(() => { + if (!userToken) return; + (async () => { + const token = await getExpoPushToken(); + if (token) { + try { await registerPushToken(token); } catch (_) {} + } + })(); + }, [userToken]); + if (isLoading) { return ( + )} diff --git a/front/src/screens/Profile/EditProfileScreen.js b/front/src/screens/Profile/EditProfileScreen.js index 0b49faa..08af966 100644 --- a/front/src/screens/Profile/EditProfileScreen.js +++ b/front/src/screens/Profile/EditProfileScreen.js @@ -147,7 +147,7 @@ export default function EditProfileScreen({ navigation }) { try { await setBirthdate(dateObj.toISOString()); await refetchUser(); - showToast('Date de naissance enregistrée. Ton coffre est dans ton inventaire ! 🎁', 'success'); + showToast('Date de naissance enregistrée. Ton coffre est dans ton inventaire !', 'success'); } catch (error) { if (error.isSessionExpired) throw error; const msg = error.data?.message || error.message || 'Erreur réseau. Réessaie dans un instant.'; diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index c8fdfb3..2cf1842 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -79,7 +79,7 @@ export default function InventoryScreen({ navigation }) { try { const res = await useItem(itemType); if (res.success) { - showToast(`${ITEM_CATALOG[itemType]?.name ?? itemType} utilisé ! ✨`, 'success'); + showToast(`${ITEM_CATALOG[itemType]?.name ?? itemType} utilisé !`, 'success'); refetch(); // Le Profil affiche un niveau/XP calculé localement (logs de séances), diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index b0763da..061f5cd 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -248,7 +248,7 @@ export default function SettingsScreen({ navigation }) { try { setSimLoading(true); await fireTestNotification(type); - showFeedback(type === 'orange' ? 'Notif orange dans 3 s... 🔥' : 'Notif violette dans 3 s... 👀'); + showFeedback(type === 'orange' ? 'Notif orange dans 3 s...' : 'Notif violette dans 3 s...'); } catch (e) { showFeedback('Erreur : ' + (e?.message || 'inconnue')); } finally { @@ -258,7 +258,7 @@ export default function SettingsScreen({ navigation }) { const handleGodMode = useCallback(async (val) => { await setGodMode(val); - if (val) Alert.alert('God Mode activé 🔥', 'Utilisez la console ci-dessous pour simuler votre progression.'); + if (val) Alert.alert('God Mode activé', 'Utilisez la console ci-dessous pour simuler votre progression.'); }, [setGodMode]); const handleLogout = () => { @@ -894,8 +894,8 @@ export default function SettingsScreen({ navigation }) { Déclenche une notification de test dans 3 secondes. Passe l'app en arrière-plan. - runNotifTest('orange')} disabled={simLoading} flex variant="orange" /> - runNotifTest('violet')} disabled={simLoading} flex variant="violet" /> + runNotifTest('orange')} disabled={simLoading} flex variant="orange" /> + runNotifTest('violet')} disabled={simLoading} flex variant="violet" /> {/* ── RESET ── */} diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index 249ee8b..4540314 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -76,7 +76,7 @@ export default function FriendProfileScreen({ route, navigation }) { setShaking(true); try { const res = await shakeMember(sharedGroup._id, friendId); - showToast(res.message || `${pseudo} a été secoué ! 🚨`, 'success'); + showToast(res.message || `${pseudo} a été secoué !`, 'success'); } catch (error) { if (!error.isSessionExpired) showToast(error.data?.message || 'Action impossible.', 'error'); } finally { diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index ec7fb48..2a871a2 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -18,6 +18,16 @@ import { } from '../../services/social.service'; import { MAJOR_EXERCISES } from '../../data/majorExercises'; +// Podium / classements : positions 1-3 affichées en médaille colorée plutôt +// qu'en emoji 🥇🥈🥉. +const MEDAL_COLORS = { 1: '#FFD700', 2: '#C0C0C0', 3: '#CD7F32' }; + +function MedalBadge({ position, size = 16 }) { + const color = MEDAL_COLORS[position]; + if (!color) return null; + return ; +} + const SEGMENTS = [ { key: 'friends', label: 'Amis', icon: 'people' }, { key: 'leaderboard', label: 'Classement', icon: 'podium' }, @@ -190,8 +200,8 @@ export default function SocialScreen({ navigation }) { results={results} searching={searching} onQueryChange={onQueryChange} - onSend={(id) => doAction(() => sendFriendRequest(id), 'Invitation envoyée ⚡')} - onAccept={(id) => doAction(() => acceptFriendRequest(id), 'Vous êtes maintenant amis ! 🤝')} + onSend={(id) => doAction(() => sendFriendRequest(id), 'Invitation envoyée')} + onAccept={(id) => doAction(() => acceptFriendRequest(id), 'Vous êtes maintenant amis !')} onDecline={(id) => doAction(() => declineFriendRequest(id))} onOpenProfile={(friend, friendshipLevel) => navigation.navigate('FriendProfile', { friendId: friend._id, pseudo: friend.pseudo, friendshipLevel })} @@ -205,16 +215,16 @@ export default function SocialScreen({ navigation }) { group={group} invites={groupInvites} friends={friends} - onInvite={(ids, name) => doAction(() => inviteToGroup(ids, name), 'Demande de Streak de Groupe envoyée 🔥')} + onInvite={(ids, name) => doAction(() => inviteToGroup(ids, name), 'Demande de Streak de Groupe envoyée')} onRespond={(groupId, accept) => - doAction(() => respondToGroupInvite(groupId, accept), accept ? 'Bienvenue dans le groupe ! 🔥' : null)} + doAction(() => respondToGroupInvite(groupId, accept), accept ? 'Bienvenue dans le groupe !' : null)} onShake={(groupId, memberId, pseudo) => - doAction(() => shakeMember(groupId, memberId), `${pseudo} a été secoué ! 🚨`)} + doAction(() => shakeMember(groupId, memberId), `${pseudo} a été secoué !`)} onCheckStreak={async (groupId) => { try { const res = await checkGroupStreak(groupId); if (res.allValidated) { - showToast(`Streak jour ${res.currentStreak} ! +${res.groupBonus?.bonusXp ?? 0} XP (x${res.groupBonus?.multiplier ?? 1}) 🔥`, 'success'); + showToast(`Streak jour ${res.currentStreak} ! +${res.groupBonus?.bonusXp ?? 0} XP (x${res.groupBonus?.multiplier ?? 1})`, 'success'); // Le bonus XP peut faire franchir un palier de niveau, refetch // le profil global pour que LevelUpCelebration le détecte, // et pousse le même gain dans les logs locaux pour que le @@ -225,7 +235,7 @@ export default function SocialScreen({ navigation }) { } if (res.bloodSangUnlocked) setBloodSangUnlockedVisible(true); } else if (res.alreadyValidated) { - showToast('Déjà validée aujourd\'hui ✅', 'success'); + showToast('Déjà validée aujourd\'hui', 'success'); } else { showToast('Tous les membres n\'ont pas encore validé leur séance.', 'error'); } @@ -319,7 +329,12 @@ function FriendsSegment({ {r.relationStatus === 'pending_received' && ( onAccept(r.requestId)} /> )} - {r.relationStatus === 'accepted' && Ami 🤝} + {r.relationStatus === 'accepted' && ( + + + Ami + + )} ))} @@ -345,7 +360,7 @@ function FriendsSegment({ MES AMIS ({friends.length}) {friends.length === 0 ? ( - 🤝 + Pas encore d'amis.{'\n'}Cherche un pseudo ci-dessus pour commencer ! ) : friends.map((f, i) => ( @@ -492,9 +507,11 @@ function RecordsLeaderboard() { ) : rows.map((entry, i) => ( - - {entry.position <= 3 ? ['🥇', '🥈', '🥉'][entry.position - 1] : `#${entry.position}`} - + + {entry.position <= 3 + ? + : #{entry.position}} + {entry.user.pseudo}{entry.isMe ? ' (moi)' : ''} @@ -527,9 +544,9 @@ function PodiumColumn({ entry, height, color, delay }) { transform: [{ scaleY: grow }], }]} > - - {entry.position === 1 ? '🥇' : entry.position === 2 ? '🥈' : '🥉'} - + + + ); @@ -665,6 +682,19 @@ function GroupCard({ group, onShake, onCheckStreak, onLeaveGroup }) { + {/* ── Hall of Shame : reste affiché jusqu'à la prochaine streak validée ── */} + {(group.shameBreakers ?? []).length > 0 && ( + + + + Briseur{group.shameBreakers.length > 1 ? 's' : ''} de Streak actuel : {' '} + + {group.shameBreakers.map((b) => b.pseudo).join(', ')} + + + + )} + {/* ── Détail du multiplicateur d'XP ── */} @@ -760,15 +790,34 @@ function AnimatedRow({ index, children }) { ); } +// Météo des séances : 4 états stricts remontés par le backend (getMyGroup) +// dans `member.weatherStatus`. Affiché uniquement quand présent (les listes +// amis/requêtes n'en portent pas — seuls les membres de groupe en ont un). +const WEATHER_META = { + done: { icon: 'checkmark-circle', color: '#22C55E', label: 'Séance validée' }, + active: { icon: 'flash', color: '#FBBF24', label: 'Séance active' }, + ready: { icon: 'flame', color: Colors.primary, label: 'Prêt' }, + sleeping: { icon: 'moon', color: Colors.textMuted, label: 'En sommeil' }, +}; + function UserRow({ user, children }) { + const weather = user?.weatherStatus ? WEATHER_META[user.weatherStatus] : null; return ( {(user?.pseudo ?? '?').charAt(0).toUpperCase()} + {weather && ( + + + + )} {user?.pseudo ?? '—'} - Nv. {user?.level ?? 1} · {user?.rank ?? 'Novice'} + + Nv. {user?.level ?? 1} · {user?.rank ?? 'Novice'} + {weather ? ` · ${weather.label}` : ''} + {children} @@ -861,11 +910,19 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', marginRight: 11, }, avatarTxt: { color: Colors.primary, fontSize: 16, fontWeight: '800' }, + weatherBadge: { + position: 'absolute', bottom: -4, right: -4, + width: 18, height: 18, borderRadius: 9, + backgroundColor: Colors.cardDeep, + borderWidth: 1.5, borderColor: Colors.background, + justifyContent: 'center', alignItems: 'center', + }, userPseudo: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' }, userMeta: { color: Colors.textMuted, fontSize: 11.5, marginTop: 1 }, userActions:{ flexDirection: 'row', alignItems: 'center', gap: 6 }, pendingTag: { color: Colors.textMuted, fontSize: 12, fontWeight: '600' }, - friendTag: { color: Colors.valid, fontSize: 12, fontWeight: '700' }, + friendTagRow: { flexDirection: 'row', alignItems: 'center', gap: 4 }, + friendTag: { color: Colors.textMuted, fontSize: 12, fontWeight: '700' }, hearts: { flexDirection: 'row', alignItems: 'center' }, smallBtn: { @@ -886,7 +943,7 @@ const styles = StyleSheet.create({ width: '100%', borderRadius: 12, borderWidth: 1, justifyContent: 'flex-start', alignItems: 'center', paddingTop: 8, }, - podiumMedal: { fontSize: 22 }, + podiumMedal: { alignItems: 'center', justifyContent: 'center' }, boardRow: { flexDirection: 'row', alignItems: 'center', @@ -895,7 +952,8 @@ const styles = StyleSheet.create({ borderRadius: 12, paddingHorizontal: 14, height: 46, marginBottom: 7, }, boardRowMe: { borderColor: 'rgba(254,116,57,0.45)', backgroundColor: 'rgba(254,116,57,0.06)' }, - boardPos: { color: Colors.textMuted, fontSize: 13, fontWeight: '800', width: 36 }, + boardPos: { width: 36 }, + boardPosTxt: { color: Colors.textMuted, fontSize: 13, fontWeight: '800' }, boardPseudo: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' }, boardXp: { color: Colors.textSecondary, fontSize: 12.5, fontWeight: '700' }, boardKg: { color: Colors.primary, fontSize: 13.5, fontWeight: '800' }, @@ -949,6 +1007,21 @@ const styles = StyleSheet.create({ groupName: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' }, groupStreak: { color: Colors.textSecondary, fontSize: 12.5, marginTop: 3 }, + // ── Hall of Shame ── + shameBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(56,189,248,0.10)', + borderWidth: 1, + borderColor: 'rgba(56,189,248,0.35)', + borderRadius: 12, + padding: 10, + marginTop: 12, + gap: 8, + }, + shameTxt: { color: Colors.textSecondary, fontSize: 12.5, flex: 1, lineHeight: 18 }, + shameNames: { color: '#38BDF8', fontWeight: '800' }, + // ── Détail multiplicateur ── multiplierCard: { backgroundColor: 'rgba(255,215,0,0.05)', @@ -1014,7 +1087,6 @@ const styles = StyleSheet.create({ // ── Vide ── emptyBox: { alignItems: 'center', paddingVertical: 36 }, - emptyEmoji: { fontSize: 38, marginBottom: 10 }, emptyTxt: { color: Colors.textMuted, fontSize: 13, lineHeight: 20, textAlign: 'center' }, emptySmall: { color: Colors.textMuted, fontSize: 12.5, marginTop: 8, marginLeft: 4 }, }); diff --git a/front/src/screens/Workouts/CustomExercisesScreen.js b/front/src/screens/Workouts/CustomExercisesScreen.js index dda6d24..e5ba89f 100644 --- a/front/src/screens/Workouts/CustomExercisesScreen.js +++ b/front/src/screens/Workouts/CustomExercisesScreen.js @@ -61,7 +61,7 @@ export default function CustomExercisesScreen({ navigation }) { activeOpacity={0.85} > - {icon} + {item.name} diff --git a/front/src/screens/Workouts/ManualWorkoutCreatorScreen.js b/front/src/screens/Workouts/ManualWorkoutCreatorScreen.js index 9f35a3e..d0a6e22 100644 --- a/front/src/screens/Workouts/ManualWorkoutCreatorScreen.js +++ b/front/src/screens/Workouts/ManualWorkoutCreatorScreen.js @@ -46,7 +46,7 @@ function ExerciseRow({ exercise, index, onRemove, onSetsChange, onRepsChange }) - {icon} + {exercise.name} diff --git a/front/src/screens/Workouts/WorkoutBuilderScreen.js b/front/src/screens/Workouts/WorkoutBuilderScreen.js index d18fe46..c332a0b 100644 --- a/front/src/screens/Workouts/WorkoutBuilderScreen.js +++ b/front/src/screens/Workouts/WorkoutBuilderScreen.js @@ -216,7 +216,7 @@ export default function WorkoutBuilderScreen({ navigation }) { preview.exercises.map((ex, i) => ( - {pickExerciseIcon(ex)} + {ex.name} diff --git a/front/src/screens/Workouts/WorkoutListScreen.js b/front/src/screens/Workouts/WorkoutListScreen.js index f3af7b6..3d0bd8f 100644 --- a/front/src/screens/Workouts/WorkoutListScreen.js +++ b/front/src/screens/Workouts/WorkoutListScreen.js @@ -32,7 +32,7 @@ function TemplateCard({ template, onPress }) { activeOpacity={0.85} > - {template.icon} + {template.name} diff --git a/front/src/screens/Workouts/WorkoutScreen.js b/front/src/screens/Workouts/WorkoutScreen.js index fa7b775..f698426 100644 --- a/front/src/screens/Workouts/WorkoutScreen.js +++ b/front/src/screens/Workouts/WorkoutScreen.js @@ -29,6 +29,7 @@ import AddExerciseSheet from '../../components/workouts/AddExerciseSheet'; import WorkoutRecapModal from '../../components/workouts/WorkoutRecapModal'; import ShortSessionWarningModal from '../../components/workouts/ShortSessionWarningModal'; import QuestToast from '../../components/common/QuestToast'; +import ConfirmModal from '../../components/common/ConfirmModal'; const DEFAULT_FILTERS = { muscles: [], levels: [], equipment: [] }; @@ -68,6 +69,14 @@ export default function WorkoutScreen({ route, navigation }) { const [recapVisible, setRecapVisible] = useState(false); const [recapData, setRecapData] = useState(null); + // Popup d'abandon de séance : intercepte toute sortie de l'écran (geste, + // bouton retour matériel, action programmatique) tant qu'une progression + // réelle existe. `allowExitRef` sert d'échappatoire pour les sorties déjà + // voulues par l'utilisateur (fin de séance validée via closeRecap). + const [abandonModalVisible, setAbandonModalVisible] = useState(false); + const allowExitRef = useRef(false); + const pendingNavActionRef = useRef(null); + // Quest toast queue const [currentToast, setCurrentToast] = useState(null); const toastQueueRef = useRef([]); @@ -95,6 +104,36 @@ export default function WorkoutScreen({ route, navigation }) { const filteredExercises = useExerciseSorting(sourceExercises, filters); const visibleExercises = filterActive ? filteredExercises : sourceExercises; + // Popup d'abandon de séance : dès qu'un exercice a été ajouté, quitter + // l'écran (geste retour, bouton matériel Android, navigation programmatique) + // annule une vraie progression — on l'intercepte pour confirmer. + useEffect(() => { + if (!navigation) return undefined; + const listener = (e) => { + if (allowExitRef.current || sourceExercises.length === 0) return; + e.preventDefault(); + pendingNavActionRef.current = e.data.action; + setAbandonModalVisible(true); + }; + const unsubscribe = navigation.addListener('beforeRemove', listener); + return unsubscribe; + }, [navigation, sourceExercises.length]); + + const confirmAbandon = useCallback(() => { + setAbandonModalVisible(false); + allowExitRef.current = true; + actions.reset(); + if (pendingNavActionRef.current) { + navigation.dispatch(pendingNavActionRef.current); + pendingNavActionRef.current = null; + } + }, [navigation, actions]); + + const cancelAbandon = useCallback(() => { + setAbandonModalVisible(false); + pendingNavActionRef.current = null; + }, []); + const displayItems = useMemo(() => { if (!Array.isArray(visibleExercises) || visibleExercises.length === 0) return []; @@ -293,6 +332,9 @@ export default function WorkoutScreen({ route, navigation }) { // Reset the workout context so the next session starts clean actions.reset(); if (navigation) { + // Séance déjà validée : cette sortie ne doit jamais déclencher la popup + // d'abandon (voir le listener 'beforeRemove' plus haut). + allowExitRef.current = true; // Pop the entire WorkoutStack back to WorkoutList (the root screen), // then switch to Stats tab. Without popToTop(), WorkoutScreen stays on the // stack and the user lands back here when they tap "Séances" again. @@ -501,6 +543,18 @@ export default function WorkoutScreen({ route, navigation }) { elapsedSeconds={elapsed} /> + + ); } diff --git a/front/src/services/notificationService.js b/front/src/services/notificationService.js index 4202e8d..b7055ce 100644 --- a/front/src/services/notificationService.js +++ b/front/src/services/notificationService.js @@ -1,5 +1,6 @@ import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; +import Constants from 'expo-constants'; import AsyncStorage from '@react-native-async-storage/async-storage'; const DAILY_NOTIF_IDS_KEY = 'athly:notif:daily_ids:v2'; @@ -85,6 +86,31 @@ export async function requestNotificationPermissions() { return status === 'granted'; } +/** + * Récupère le token Expo Push de l'appareil courant, en demandant la + * permission si besoin. Retourne null sur web (non supporté), en cas de + * permission refusée, ou si l'obtention échoue (jamais d'exception qui + * remonterait — un push cross-device est un bonus UX, pas une dépendance + * bloquante pour le reste de l'app). + */ +export async function getExpoPushToken() { + if (Platform.OS === 'web') return null; + try { + const { status: existing } = await Notifications.getPermissionsAsync(); + let status = existing; + if (status !== 'granted') { + ({ status } = await Notifications.requestPermissionsAsync()); + } + if (status !== 'granted') return null; + + const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId; + const { data } = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined); + return data ?? null; + } catch (_) { + return null; + } +} + function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; } diff --git a/front/src/services/profile.service.js b/front/src/services/profile.service.js index 05630f4..f67485f 100644 --- a/front/src/services/profile.service.js +++ b/front/src/services/profile.service.js @@ -13,3 +13,11 @@ export async function updateShowcase(achievementIds) { const res = await API.put('/users/me/showcase', { achievementIds }); return res.data; } + +// Enregistre (ou efface, si null) le token Expo Push de l'appareil courant — +// nécessaire pour recevoir les notifications réellement envoyées par un +// autre appareil (bouton Secouer, réactions du flux d'activité...). +export async function registerPushToken(pushToken) { + const res = await API.put('/users/me/push-token', { pushToken }); + return res.data; +} diff --git a/front/src/services/social.service.js b/front/src/services/social.service.js index eceeb62..3055bcf 100644 --- a/front/src/services/social.service.js +++ b/front/src/services/social.service.js @@ -80,6 +80,18 @@ export async function leaveGroup() { return res.data; } +// ─── Flux d'activité « Taquineries & High-Fives » (Brique IV) ───────────────── + +export async function getActivityFeed() { + const res = await API.get('/activity/feed'); + return res.data; +} + +export async function reactToActivityEvent(eventId, emoji) { + const res = await API.post(`/activity/${eventId}/react`, { emoji }); + return res.data; +} + // ─── Parrainage (Brique III) ────────────────────────────────────────────────── export async function claimReferral(code) { From f7666bb06588e74503f4804facf901f7f4f5d12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Wed, 8 Jul 2026 17:45:07 +0200 Subject: [PATCH 17/31] =?UTF-8?q?feat(performance):=20historique=20et=20gr?= =?UTF-8?q?aphique=20de=20poids=20double=20courbe,=20rappels=20hebdomadair?= =?UTF-8?q?es,=20tags=20num=C3=A9riques=20et=20preview=20d'amis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/app.js | 2 + back/controllers/debug.controller.js | 290 ++++++++ back/controllers/exercise.controller.js | 33 + back/controllers/friend.controller.js | 146 ++++- back/controllers/groupStreak.controller.js | 37 +- back/controllers/user.controller.js | 21 + back/controllers/weight.controller.js | 63 ++ back/data/shakeMessages.js | 26 + back/models/StreakGroup.js | 12 + back/models/User.js | 31 + back/models/WeightHistory.js | 36 + back/routes/debug.routes.js | 9 + back/routes/exercise.routes.js | 4 + back/routes/friend.routes.js | 6 +- back/routes/user.routes.js | 5 +- back/routes/weight.routes.js | 15 + back/scripts/backfillDiscriminators.js | 41 ++ back/services/auth.service.js | 25 +- back/services/user.service.js | 33 + back/tests/debug.test.js | 201 ++++++ back/tests/discriminator.test.js | 138 ++++ back/tests/groupStreak.test.js | 27 + back/tests/recordsShowcase.test.js | 172 +++++ back/tests/socialEngine.test.js | 161 ++++- back/tests/weight.test.js | 132 ++++ back/validators/user.validator.js | 4 + back/validators/weight.validator.js | 10 + .../src/components/common/WeightEntryModal.js | 177 +++++ .../components/profile/BirthdayCelebration.js | 14 +- front/src/components/profile/BirthdayModal.js | 127 +++- .../components/profile/PersonalRecordsList.js | 29 +- .../profile/RecordsShowcasePicker.js | 226 +++++++ .../components/social/ActivityFeedModal.js | 14 +- front/src/components/social/AddFriendModal.js | 221 +++++++ .../components/social/ExercisePickerModal.js | 170 +++++ .../components/social/FriendPreviewModal.js | 169 +++++ .../components/stats/WeightProgressChart.js | 147 +++++ .../components/stats/WeightReminderCheck.js | 63 ++ .../components/stats/WeightReminderModal.js | 113 ++++ front/src/data/majorExercises.js | 23 + front/src/navigation/index.js | 2 + front/src/screens/Profile/ProfileScreen.js | 57 +- front/src/screens/Profile/SettingsScreen.js | 619 ++++++++++++------ .../src/screens/Social/FriendProfileScreen.js | 76 ++- front/src/screens/Social/SocialScreen.js | 267 +++++--- front/src/screens/Stats/StatsScreen.js | 45 ++ front/src/services/debug.service.js | 42 ++ front/src/services/profile.service.js | 7 + front/src/services/social.service.js | 19 + front/src/services/weight.service.js | 15 + 50 files changed, 3883 insertions(+), 439 deletions(-) create mode 100644 back/controllers/weight.controller.js create mode 100644 back/data/shakeMessages.js create mode 100644 back/models/WeightHistory.js create mode 100644 back/routes/weight.routes.js create mode 100644 back/scripts/backfillDiscriminators.js create mode 100644 back/tests/discriminator.test.js create mode 100644 back/tests/recordsShowcase.test.js create mode 100644 back/tests/weight.test.js create mode 100644 back/validators/weight.validator.js create mode 100644 front/src/components/common/WeightEntryModal.js create mode 100644 front/src/components/profile/RecordsShowcasePicker.js create mode 100644 front/src/components/social/AddFriendModal.js create mode 100644 front/src/components/social/ExercisePickerModal.js create mode 100644 front/src/components/social/FriendPreviewModal.js create mode 100644 front/src/components/stats/WeightProgressChart.js create mode 100644 front/src/components/stats/WeightReminderCheck.js create mode 100644 front/src/components/stats/WeightReminderModal.js create mode 100644 front/src/services/weight.service.js diff --git a/back/app.js b/back/app.js index 2b84fff..28a36ed 100644 --- a/back/app.js +++ b/back/app.js @@ -16,6 +16,7 @@ const rewardRoutes = require("./routes/reward.routes"); const referralRoutes = require("./routes/referral.routes"); const debugRoutes = require("./routes/debug.routes"); const activityRoutes = require("./routes/activity.routes"); +const weightRoutes = require("./routes/weight.routes"); // --- Importation des middlewares --- const errorMiddleware = require("./middleware/error.middleware"); @@ -96,6 +97,7 @@ app.use("/api/rewards", rewardRoutes); app.use("/api/referral", referralRoutes); app.use("/api/debug", debugRoutes); app.use("/api/activity", activityRoutes); +app.use("/api/weight", weightRoutes); // --- Gestion des erreurs --- // Route 404 diff --git a/back/controllers/debug.controller.js b/back/controllers/debug.controller.js index 7e2254f..d943d8c 100644 --- a/back/controllers/debug.controller.js +++ b/back/controllers/debug.controller.js @@ -4,9 +4,15 @@ const crypto = require('crypto'); const bcrypt = require('bcrypt'); const User = require('../models/User'); const Friendship = require('../models/Friendship'); +const StreakGroup = require('../models/StreakGroup'); +const Workout = require('../models/Workout'); const { xpForLevel, getRankForLevel } = require('../utils/levelHelpers'); const { addItemAtomic, addUniqueItemOnce } = require('../services/inventory.service'); const { checkAndUnlockAchievements } = require('./reward.controller'); +const { recordActivityEvent } = require('../services/activity.service'); +const { sendPushToUser } = require('../services/push.service'); +const { SHAKE_TROLL_MESSAGES } = require('../data/shakeMessages'); +const { uniqueDiscriminator } = require('../services/auth.service'); const MOCK_PASSWORD_ROUNDS = 10; // comptes jetables, jamais utilisés pour se connecter @@ -474,3 +480,287 @@ exports.mockSocial = async (req, res, next) => { next(err); } }; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateGroup POST /api/debug/godmode/simulate-group +// ───────────────────────────────────────────────────────────────────────────── + +// 3 coéquipiers factices couvrant les 3 statuts non-« sommeil » de la Météo +// des séances — le 4e statut (💤 En sommeil) s'obtient naturellement en ne +// touchant à rien pour un membre. Namespacés par l'ObjectId de l'appelant +// (comme MOCK_FRIEND_SPECS) pour rester idempotent et ne jamais fuiter d'un +// testeur à l'autre. +const MOCK_GROUPMATE_WEATHER = [ + { suffix: 1, pseudo: 'Coequipier_Pret', weather: 'ready' }, + { suffix: 2, pseudo: 'Coequipier_Actif', weather: 'active' }, + { suffix: 3, pseudo: 'Coequipier_Valide', weather: 'done' }, +]; + +const SIMULATED_GROUP_STREAK = 5; + +/** + * Outil de test : crée (ou régénère) un groupe de streak complet avec 3 + * coéquipiers factices, un par statut de Météo des séances testable sans + * seconde app/appareil (ready/active/done — le 4e, sleeping, s'obtient en ne + * touchant à aucun des trois). Seul moyen de tester weatherStatus, le + * multiplicateur de groupe et le bouton "Secouer" en solo. + * + * Idempotent : le groupe et les coéquipiers précédents de CET utilisateur + * (namespacés par son ObjectId) sont supprimés avant recréation. + */ +exports.simulateGroup = async (req, res, next) => { + try { + const myId = req.user.id; + const emailPrefix = `mock-group-${myId}-`; + + // ── Nettoyage du groupe et des coéquipiers précédents de CET utilisateur ── + const previousMocks = await User.find({ email: { $regex: `^${emailPrefix}` } }).select('_id'); + const previousIds = previousMocks.map((u) => u._id); + if (previousIds.length > 0) { + await Workout.deleteMany({ user: { $in: previousIds } }); + await Friendship.deleteMany({ + $or: [{ requester: { $in: previousIds } }, { recipient: { $in: previousIds } }], + }); + await User.deleteMany({ _id: { $in: previousIds } }); + } + await StreakGroup.deleteMany({ members: myId }); + + const passwordHash = await bcrypt.hash(crypto.randomBytes(24).toString('hex'), MOCK_PASSWORD_ROUNDS); + const now = new Date(); + const todayAt = (h, m) => { const d = new Date(); d.setHours(h, m, 0, 0); return d; }; + + const memberIds = [myId]; + for (const spec of MOCK_GROUPMATE_WEATHER) { + const mockUser = await User.create({ + pseudo: spec.pseudo, + email: `${emailPrefix}${spec.suffix}@athly.dev`, + password: passwordHash, + isVerified: true, + level: 15, + xp: xpForLevel(15), + rank: getRankForLevel(15), + lastActiveAt: spec.weather === 'ready' || spec.weather === 'active' || spec.weather === 'done' ? now : null, + }); + + if (spec.weather === 'active') { + await Workout.create({ user: mockUser._id, status: 'in_progress', date: todayAt(12, 0) }); + } else if (spec.weather === 'done') { + await Workout.create({ user: mockUser._id, status: 'finished', date: todayAt(8, 0) }); + } + + await Friendship.create({ requester: myId, recipient: mockUser._id, status: 'accepted' }); + memberIds.push(mockUser._id); + } + + const group = await StreakGroup.create({ + name: 'Groupe de Test (God Mode)', + members: memberIds, + currentStreak: SIMULATED_GROUP_STREAK, + lastValidatedDate: now, + }); + + return res.status(201).json({ + success: true, + message: `Groupe de test créé (3 coéquipiers : Prêt/Actif/Validé, streak ${SIMULATED_GROUP_STREAK}j).`, + groupId: group._id, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateActivityEvent POST /api/debug/godmode/simulate-activity-event +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : publie un ActivityEvent factice au nom d'un coéquipier du + * groupe de l'utilisateur connecté (jamais en son propre nom — le flux + * n'affiche jamais ses propres événements), pour tester ActivityFeedModal et + * les réactions sans attendre qu'un vrai coéquipier batte un record ou ouvre + * un coffre Légendaire. + * + * Body : { type?: 'pr_broken' | 'chest_legendary' } — défaut aléatoire. + */ +exports.simulateActivityEvent = async (req, res, next) => { + try { + const myId = req.user.id; + const requestedType = (req.body || {}).type; + const type = requestedType === 'chest_legendary' ? 'chest_legendary' + : requestedType === 'pr_broken' ? 'pr_broken' + : (Math.random() < 0.5 ? 'pr_broken' : 'chest_legendary'); + + const group = await StreakGroup.findOne({ members: myId }); + if (!group) { + return next(createError('Aucun groupe — utilise "Simuler un groupe" avant de tester le flux d\'activité.', 400)); + } + + const otherMemberId = group.members.find((m) => m.toString() !== myId); + if (!otherMemberId) { + return next(createError('Ton groupe ne contient aucun autre membre.', 400)); + } + + const actor = await User.findById(otherMemberId).select('pseudo'); + const pseudo = actor?.pseudo ?? 'Un coéquipier'; + + const message = type === 'chest_legendary' + ? `${pseudo} a ouvert un coffre Légendaire !` + : `${pseudo} a brisé son record au Développé couché !`; + const payload = type === 'chest_legendary' + ? { itemType: 'LEVEL_COUPON' } + : { exercise: 'Développé couché', weight: 100 }; + + const event = await recordActivityEvent(otherMemberId, type, message, payload); + + return res.status(201).json({ + success: true, + message: `Événement simulé : ${message}`, + eventId: event?._id ?? null, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateStreakBreak POST /api/debug/godmode/simulate-streak-break +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : recule artificiellement `lastValidatedDate` du groupe de + * l'utilisateur connecté de 2 jours (en gardant currentStreak > 0), pour que + * le prochain `getMyGroup` déclenche la détection paresseuse de rupture de + * streak (voir detectAndApplyStreakBreak dans groupStreak.controller.js) et + * affiche le bandeau Hall of Shame — sans attendre un vrai jour manqué. + * + * Les coéquipiers factices (créés par "Simuler un groupe") n'ont par + * construction aucune séance ni Gel de Streak sur le jour manqué : ils + * deviennent naturellement les "briseurs" désignés. + */ +exports.simulateStreakBreak = async (req, res, next) => { + try { + const myId = req.user.id; + const group = await StreakGroup.findOne({ members: myId }); + if (!group) { + return next(createError('Aucun groupe — utilise "Simuler un groupe" avant de tester le Hall of Shame.', 400)); + } + + const twoDaysAgo = new Date(); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + + group.currentStreak = group.currentStreak > 0 ? group.currentStreak : SIMULATED_GROUP_STREAK; + group.lastValidatedDate = twoDaysAgo; + group.shameBreakers = []; + await group.save(); + + return res.status(200).json({ + success: true, + message: 'Rupture de streak simulée — recharge l\'onglet Groupe pour voir le Hall of Shame.', + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateShakeSelf POST /api/debug/godmode/simulate-shake-self +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : envoie une VRAIE notification push au token Expo enregistré + * de l'utilisateur connecté (pas à un autre membre), avec le même texte + * troll que le vrai bouton "Secouer" — seul moyen de vérifier de bout en + * bout que l'infra push (Expo → appareil) fonctionne réellement en solo, + * sans second compte/appareil pour recevoir la notification. + */ +exports.simulateShakeSelf = async (req, res, next) => { + try { + const user = await User.findById(req.user.id).select('pushToken'); + if (!user?.pushToken) { + return next(createError("Aucun token push enregistré sur ce compte — ouvre l'app avec les notifications autorisées d'abord.", 400)); + } + + const trollMessage = SHAKE_TROLL_MESSAGES[Math.floor(Math.random() * SHAKE_TROLL_MESSAGES.length)]; + const pushed = await sendPushToUser(req.user.id, { + title: 'Athly (Test) t\'a secoué ! 🚨', + body: trollMessage, + data: { type: 'shake', test: true }, + }); + + return res.status(200).json({ + success: true, + pushed, + message: pushed ? 'Notification envoyée à ton appareil.' : "Échec d'envoi — le token est peut-être périmé.", + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateSearchableFriend POST /api/debug/godmode/simulate-searchable-friend +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : crée un compte factice PAS déjà ami (contrairement à + * mockSocial, qui pré-accepte les faux profils) — seul moyen de tester en + * solo le parcours complet "Ajouter un ami" : recherche par tag exact, + * carte Preview, puis envoi réel de la demande. + * + * Idempotent : rejouer l'outil retourne le même compte de test (même tag) + * plutôt que d'en empiler des dizaines — sauf s'il a depuis été ami ou + * mis en pending, auquel cas un nouveau compte "cherchable" est recréé. + */ +exports.simulateSearchableFriend = async (req, res, next) => { + try { + const myId = req.user.id; + const emailPrefix = `mock-searchable-${myId}-`; + + const existing = await User.findOne({ email: { $regex: `^${emailPrefix}` } }).select('_id pseudo discriminator'); + if (existing) { + const relation = await Friendship.findOne({ + $or: [ + { requester: myId, recipient: existing._id }, + { requester: existing._id, recipient: myId }, + ], + }); + if (!relation) { + return res.status(200).json({ + success: true, + message: 'Compte de test déjà disponible.', + pseudo: existing.pseudo, + discriminator: existing.discriminator, + tag: `${existing.pseudo}#${existing.discriminator}`, + }); + } + // Déjà ami/pending avec ce compte : on le retire pour en recréer un frais + // (sinon la recherche renverrait toujours relationStatus != 'none'). + await Friendship.deleteOne({ _id: relation._id }); + await User.deleteOne({ _id: existing._id }); + } + + const passwordHash = await bcrypt.hash(crypto.randomBytes(24).toString('hex'), MOCK_PASSWORD_ROUNDS); + const pseudo = 'TestAmi'; + + const created = await User.create({ + pseudo, + email: `${emailPrefix}${Date.now()}@athly.dev`, + password: passwordHash, + isVerified: true, + discriminator: await uniqueDiscriminator(pseudo), + level: 18, + xp: xpForLevel(18), + rank: getRankForLevel(18), + }); + + return res.status(201).json({ + success: true, + message: 'Compte de test créé.', + pseudo: created.pseudo, + discriminator: created.discriminator, + tag: `${created.pseudo}#${created.discriminator}`, + }); + } catch (err) { + next(err); + } +}; diff --git a/back/controllers/exercise.controller.js b/back/controllers/exercise.controller.js index 5586e49..cba4bc9 100644 --- a/back/controllers/exercise.controller.js +++ b/back/controllers/exercise.controller.js @@ -131,3 +131,36 @@ exports.getExerciseLeaderboard = async (req, res, next) => { next(error); } }; + +/** + * MES RECORDS (TOUS EXERCICES) + * GET /api/exercises/my-records + * + * Agrège, pour CHAQUE exercice que j'ai déjà pratiqué, mon meilleur poids et + * mes répétitions associées — alimente le sélecteur "mettre en avant jusqu'à + * 6 records" du profil (Section III) : l'utilisateur choisit parmi les + * exercices qu'il a réellement faits, pas le catalogue entier. + */ +exports.getMyRecords = async (req, res, next) => { + try { + const mongoose = require("mongoose"); + const ExerciseRecord = require("../models/ExerciseRecord"); + + const rows = await ExerciseRecord.aggregate([ + { $match: { user: new mongoose.Types.ObjectId(req.user.id) } }, + { $unwind: "$series" }, + { $group: { + _id: "$exerciceNom", + maxPoids: { $max: "$series.poids" }, + maxReps: { $max: "$series.repetitions" }, + } }, + { $sort: { maxPoids: -1 } }, + ]); + + const records = rows.map((r) => ({ exercice: r._id, maxPoids: r.maxPoids, maxReps: r.maxReps })); + + return res.status(200).json({ success: true, count: records.length, records }); + } catch (error) { + next(error); + } +}; diff --git a/back/controllers/friend.controller.js b/back/controllers/friend.controller.js index 5e7e7f8..96920d7 100644 --- a/back/controllers/friend.controller.js +++ b/back/controllers/friend.controller.js @@ -32,7 +32,7 @@ function isValidId(id) { * Champs publics d'un ami renvoyés dans les listes. * On n'expose jamais password, verificationCode, resetPasswordCode… */ -const FRIEND_PUBLIC_FIELDS = 'pseudo level rank xp'; +const FRIEND_PUBLIC_FIELDS = 'pseudo discriminator level rank xp equippedFrame'; /** * Streak courante (jours consécutifs) calculée depuis des dates de séances @@ -305,56 +305,137 @@ exports.getFriendsList = async (req, res, next) => { // getPendingRequests GET /api/friends/pending // ───────────────────────────────────────────────────────────────────────────── /** - * Récupère les demandes d'ami reçues et en attente pour l'utilisateur connecté. - * Seules les demandes dont JE suis le recipient sont retournées. + * Récupère les demandes d'ami en attente pour l'utilisateur connecté, dans + * les deux sens : + * - `requests` : demandes REÇUES (je suis recipient) — inchangé, back-compat. + * - `sent` : demandes ENVOYÉES par moi, toujours en attente de réponse. */ exports.getPendingRequests = async (req, res, next) => { try { const myId = req.user.id; - const requests = await Friendship.find({ - recipient: myId, - status: 'pending', - }) - .populate('requester', FRIEND_PUBLIC_FIELDS) - .sort({ createdAt: -1 }); + const [requests, sent] = await Promise.all([ + Friendship.find({ recipient: myId, status: 'pending' }) + .populate('requester', FRIEND_PUBLIC_FIELDS) + .sort({ createdAt: -1 }), + Friendship.find({ requester: myId, status: 'pending' }) + .populate('recipient', FRIEND_PUBLIC_FIELDS) + .sort({ createdAt: -1 }), + ]); return res.status(200).json({ success: true, count: requests.length, requests, + sentCount: sent.length, + sent, }); } catch (err) { next(err); } }; +// ───────────────────────────────────────────────────────────────────────────── +// cancelFriendRequest DELETE /api/friends/request/:requestId +// ───────────────────────────────────────────────────────────────────────────── +/** + * Annule une demande d'ami que J'AI ENVOYÉE et qui est toujours en attente — + * pendant du "refuser" côté destinataire, mais côté expéditeur. + */ +exports.cancelFriendRequest = async (req, res, next) => { + try { + const myId = req.user.id; + const { requestId } = req.params; + + if (!isValidId(requestId)) { + return next(createError('requestId invalide.', 400)); + } + + const friendship = await Friendship.findById(requestId); + if (!friendship) { + return next(createError('Demande d\'ami introuvable.', 404)); + } + if (friendship.requester.toString() !== myId) { + return next(createError('Action non autorisée : vous n\'êtes pas l\'auteur de cette demande.', 403)); + } + if (friendship.status !== 'pending') { + return next(createError(`Impossible d'annuler une demande au statut "${friendship.status}".`, 422)); + } + + await friendship.deleteOne(); + + return res.status(200).json({ success: true, message: 'Demande d\'ami annulée.' }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// removeFriend DELETE /api/friends/:friendshipId +// ───────────────────────────────────────────────────────────────────────────── +/** + * Supprime une amitié ACCEPTÉE (retire un ami) — l'un ou l'autre membre de + * la relation peut la rompre. Ne touche pas à un éventuel groupe de streak + * partagé (retrait manuel séparé, comme pour n'importe quel autre membre). + */ +exports.removeFriend = async (req, res, next) => { + try { + const myId = req.user.id; + const { friendshipId } = req.params; + + if (!isValidId(friendshipId)) { + return next(createError('friendshipId invalide.', 400)); + } + + const friendship = await Friendship.findById(friendshipId); + if (!friendship) { + return next(createError('Amitié introuvable.', 404)); + } + const isMember = friendship.requester.toString() === myId || friendship.recipient.toString() === myId; + if (!isMember) { + return next(createError('Action non autorisée : vous ne faites pas partie de cette amitié.', 403)); + } + if (friendship.status !== 'accepted') { + return next(createError('Cette relation n\'est pas une amitié active.', 422)); + } + + await friendship.deleteOne(); + + return res.status(200).json({ success: true, message: 'Ami retiré.' }); + } catch (err) { + next(err); + } +}; + // ───────────────────────────────────────────────────────────────────────────── // searchUsers GET /api/friends/search?q= // ───────────────────────────────────────────────────────────────────────────── +// Format exact "Pseudo#1234" — Section III : plus de recherche floue par +// substring, on exige le tag complet pour éviter d'ajouter la mauvaise +// personne (deux comptes peuvent partager le même pseudo). +const FULL_TAG_PATTERN = /^(.+)#(\d{4})$/; + /** - * Recherche d'utilisateurs par pseudo (insensible à la casse). - * Chaque résultat est annoté du statut de relation avec l'utilisateur - * connecté : none | pending_sent | pending_received | accepted. - * La regex est échappée : aucune injection de pattern possible. + * Recherche d'un utilisateur par tag EXACT "Pseudo#1234" (insensible à la + * casse sur le pseudo, discriminator exact). Renvoie 0 ou 1 résultat — sert + * la carte "Preview" avant envoi d'invitation, pas une liste de suggestions. */ exports.searchUsers = async (req, res, next) => { try { const myId = req.user.id; const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''; - if (q.length < 2) { - return next(createError('La recherche doit contenir au moins 2 caractères.', 400)); + const match = q.match(FULL_TAG_PATTERN); + if (!match) { + return next(createError('Utilise le format complet "Pseudo#1234" pour rechercher un athlète.', 400)); } + const [, pseudo, discriminator] = match; - const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const users = await User.find({ - _id: { $ne: myId }, - pseudo: { $regex: escaped, $options: 'i' }, - }) + const users = await User.find({ _id: { $ne: myId }, pseudo, discriminator }) + .collation({ locale: 'en', strength: 2 }) .select(FRIEND_PUBLIC_FIELDS) - .limit(20); + .limit(1); // Annotation du statut de relation en une seule requête const ids = users.map((u) => u._id); @@ -417,14 +498,21 @@ exports.getFriendProfile = async (req, res, next) => { } const friend = await User.findById(friendId) - .select('pseudo level rank xp achievements showcasedAchievements streakGels totalWorkoutMinutes equippedFrame createdAt'); + .select('pseudo level rank xp achievements showcasedAchievements showcasedRecords streakGels totalWorkoutMinutes equippedFrame createdAt'); if (!friend) return next(createError('Utilisateur introuvable.', 404)); const friendObjectId = new mongoose.Types.ObjectId(friendId); - // Top 5 des records par poids max soulevé + // Records mis en avant par l'ami lui-même (max 6, ordre choisi) — voir + // updateRecordsShowcase. Si jamais configuré (comptes pré-migration), + // repli sur l'ancien comportement : top 5 auto par poids max. + const hasShowcase = Array.isArray(friend.showcasedRecords) && friend.showcasedRecords.length > 0; + const recordsMatch = hasShowcase + ? { user: friendObjectId, exerciceNom: { $in: friend.showcasedRecords } } + : { user: friendObjectId }; + const recordsPromise = ExerciseRecord.aggregate([ - { $match: { user: friendObjectId } }, + { $match: recordsMatch }, { $unwind: '$series' }, { $group: { _id: '$exerciceNom', @@ -432,8 +520,14 @@ exports.getFriendProfile = async (req, res, next) => { maxReps: { $max: '$series.repetitions' }, } }, { $sort: { maxPoids: -1 } }, - { $limit: 5 }, - ]); + { $limit: hasShowcase ? 6 : 5 }, + ]).then((rows) => { + if (!hasShowcase) return rows; + // Réordonne selon l'ordre choisi par l'ami (l'aggregate trie par poids, + // pas par ordre de sélection) — l'ordre de mise en avant doit primer. + const byName = new Map(rows.map((r) => [r._id, r])); + return friend.showcasedRecords.map((name) => byName.get(name)).filter(Boolean); + }); // Séances finalisées synchronisées côté backend (total + dates pour la streak) const workoutsPromise = Workout.find({ diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index d4322a9..059c802 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -9,20 +9,10 @@ const { addUniqueItemOnce } = require('../services/inventory.service'); const { checkAndUnlockAchievements } = require('./reward.controller'); const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); const { sendPushToUser } = require('../services/push.service'); +const { SHAKE_TROLL_MESSAGES } = require('../data/shakeMessages'); // ─── Constantes ─────────────────────────────────────────────────────────────── -// Bouton "Secouer" (Section IV) : textes troll/passif-agressif façon Duolingo, -// tirés au sort à chaque secousse pour ne pas être répétitifs. -const SHAKE_TROLL_MESSAGES = [ - "Ah donc tu comptes nous abandonner comme ça ? Ok.", - "Tu vas briser la streak de l'équipe... tout le monde attend après toi. 👁️", - "On sait que tu es en ligne. On voit tout.", - "Ta séance ne va pas se faire toute seule, curieusement.", - "Le groupe compte sur toi. Ou pas, si tu préfères tout gâcher.", - "Petit rappel amical : tu es le maillon faible aujourd'hui.", -]; - const MAX_GROUP_SIZE = 5; // Streak de groupe (jours consécutifs) requise, à taille maximale (5 membres), @@ -513,6 +503,15 @@ exports.shakeMember = async (req, res, next) => { return next(createError("Ce membre a déjà validé sa séance aujourd'hui — inutile de le secouer !", 422)); } + // Limite 1 secousse par jour civil et par cible — évite le harcèlement + // et donne un sens au bouton grisé côté front (voir getMyGroup). + const alreadyShakenToday = group.shakes.some((s) => + s.from.toString() === myId && s.to.toString() === memberId && s.date >= todayStart && s.date < tomorrow, + ); + if (alreadyShakenToday) { + return next(createError("Tu as déjà secoué cette personne aujourd'hui — reviens demain !", 422)); + } + const target = await User.findById(memberId).select('pseudo'); const targetPseudo = target?.pseudo ?? memberId; const me = await User.findById(myId).select('pseudo'); @@ -524,6 +523,9 @@ exports.shakeMember = async (req, res, next) => { data: { type: 'shake', fromUserId: myId }, }); + group.shakes.push({ from: myId, to: memberId, date: new Date() }); + await group.save(); + return res.status(200).json({ success: true, message: `Notification envoyée à ${targetPseudo} !`, @@ -701,9 +703,20 @@ exports.getMyGroup = async (req, res, next) => { const xpBonus = computeGroupXpBonus(group.members.length, group.currentStreak); const membersWithWeather = await attachWeatherStatuses(group.members); + // Membres déjà secoués aujourd'hui par MOI — permet au front de griser + // leur bouton "Secouer" plutôt que de laisser échouer un second clic. + const todayStart = startOfToday(); + const tomorrow = new Date(todayStart); + tomorrow.setDate(todayStart.getDate() + 1); + const shakenTodayByMe = group.shakes + .filter((s) => s.from.toString() === myId && s.date >= todayStart && s.date < tomorrow) + .map((s) => s.to.toString()); + + const { shakes: _shakes, ...groupPlain } = group.toObject(); + return res.status(200).json({ success: true, - group: { ...group.toObject(), members: membersWithWeather, xpBonus }, + group: { ...groupPlain, members: membersWithWeather, xpBonus, shakenTodayByMe }, invites, }); } catch (err) { diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index f2e6d08..fe581f1 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -68,6 +68,27 @@ exports.updateShowcase = async (req, res, next) => { } }; +/** + * METTRE À JOUR LES RECORDS MIS EN AVANT + * Synchronise la sélection de records d'exercices (max 6) affichée sur le + * profil (le sien ET celui vu par les amis) — voir getFriendProfile. + * Rejette silencieusement tout exercice jamais pratiqué (ExerciseRecord). + */ +exports.updateRecordsShowcase = async (req, res, next) => { + try { + const { exerciseNames } = req.body; + const user = await userService.updateRecordsShowcase(req.user.id, exerciseNames); + + res.status(200).json({ + success: true, + message: "Records mis en avant mis à jour", + showcasedRecords: user.showcasedRecords, + }); + } catch (error) { + next(error); + } +}; + /** * METTRE À JOUR LE CADRE DE PROFIL ÉQUIPÉ * Synchronise le choix de cadre (forme + couleur) fait localement diff --git a/back/controllers/weight.controller.js b/back/controllers/weight.controller.js new file mode 100644 index 0000000..24d1184 --- /dev/null +++ b/back/controllers/weight.controller.js @@ -0,0 +1,63 @@ +'use strict'; + +const WeightHistory = require('../models/WeightHistory'); +const User = require('../models/User'); + +// ───────────────────────────────────────────────────────────────────────────── +// getWeightHistory GET /api/weight/history +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Historique complet des pesées de l'utilisateur connecté, trié + * chronologiquement — alimente le graphique double courbe (poids / objectif) + * de l'écran Statistiques. + */ +exports.getWeightHistory = async (req, res, next) => { + try { + const history = await WeightHistory.find({ user: req.user.id }) + .select('weight date') + .sort({ date: 1 }); + + return res.status(200).json({ success: true, history }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// logWeight POST /api/weight +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Enregistre une nouvelle pesée et met à jour `user.poids` (poids courant + * affiché ailleurs dans l'app — profil, objectif) pour qu'il reste toujours + * synchronisé avec la dernière entrée de l'historique. + * + * Body : { weight: number, date?: ISO string } — date par défaut = maintenant + * (permet de renseigner une pesée d'un jour précédent depuis la modale de + * rappel hebdomadaire sans forcer "aujourd'hui"). + */ +exports.logWeight = async (req, res, next) => { + try { + const { weight } = req.body; + const date = req.body.date ? new Date(req.body.date) : new Date(); + + // `user.poids` ne doit refléter que la pesée la plus RÉCENTE — une saisie + // rétroactive (date passée) ne doit jamais écraser une entrée plus fraîche. + const latest = await WeightHistory.findOne({ user: req.user.id }).sort({ date: -1 }); + + const entry = await WeightHistory.create({ user: req.user.id, weight, date }); + + if (!latest || date >= latest.date) { + await User.updateOne({ _id: req.user.id }, { $set: { poids: weight } }); + } + + return res.status(201).json({ + success: true, + message: 'Pesée enregistrée.', + entry: { _id: entry._id, weight: entry.weight, date: entry.date }, + }); + } catch (err) { + next(err); + } +}; diff --git a/back/data/shakeMessages.js b/back/data/shakeMessages.js new file mode 100644 index 0000000..06df27f --- /dev/null +++ b/back/data/shakeMessages.js @@ -0,0 +1,26 @@ +'use strict'; + +// Bouton "Secouer" (Section IV) : textes troll/passif-agressif façon Duolingo, +// tirés au sort à chaque secousse pour ne jamais être répétitifs. Partagé +// entre le vrai shakeMember (groupStreak.controller.js) et l'outil de test +// God Mode simulateShakeSelf (debug.controller.js) — un seul pool à faire +// évoluer, jamais deux copies qui divergent. +const SHAKE_TROLL_MESSAGES = [ + "Ah donc tu comptes nous abandonner comme ça ? Ok.", + "Tu vas briser la streak de l'équipe... tout le monde attend après toi. 👁️", + "On sait que tu es en ligne. On voit tout.", + "Ta séance ne va pas se faire toute seule, curieusement.", + "Le groupe compte sur toi. Ou pas, si tu préfères tout gâcher.", + "Petit rappel amical : tu es le maillon faible aujourd'hui.", + "5 personnes attendent. 1 personne glande. Devine qui.", + "On ne dit rien, mais on pense tous la même chose.", + "Ça sent le remplacement de coéquipier, cette histoire.", + "Ton streak gel ne va pas se recharger tout seul, hein.", + "Il paraît que tu regardais encore ton téléphone à cette heure-ci.", + "Le groupe a un chat privé. Ton nom y revient beaucoup.", + "On a mis un rappel. Puis deux. Voici le troisième.", + "Une séance, ce n'est pas la mer à boire. Enfin, on croit.", + "Si la streak meurt, on saura à qui la faute.", +]; + +module.exports = { SHAKE_TROLL_MESSAGES }; diff --git a/back/models/StreakGroup.js b/back/models/StreakGroup.js index 84ce421..f57539e 100644 --- a/back/models/StreakGroup.js +++ b/back/models/StreakGroup.js @@ -43,6 +43,18 @@ const StreakGroupSchema = new mongoose.Schema( // dans groupStreak.controller.js). Vidé dès qu'une nouvelle streak est // validée avec succès — reste affiché jusque-là. shameBreakers: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], + + // Historique des secousses (bouton "Secouer") — limite chaque paire + // (from, to) à 1 secousse par jour civil, et permet au front de griser + // le bouton pour les membres déjà secoués aujourd'hui (voir shakeMember + // et getMyGroup dans groupStreak.controller.js). + shakes: [ + { + from: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + to: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + date: { type: Date, default: Date.now }, + }, + ], }, { timestamps: true } ); diff --git a/back/models/User.js b/back/models/User.js index be15d2f..719d699 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -46,6 +46,16 @@ const UserSchema = new mongoose.Schema( // ── Identité ────────────────────────────────────────────────────────────── pseudo: { type: String, trim: true }, name: { type: String, trim: true }, // conservé pour rétrocompatibilité V1 + + // Tag numérique façon Discord (Section III) : "Pseudo#1234". Le pseudo + // seul reste libre/non-unique (affiché partout dans l'app) — c'est le + // COMBO { pseudo, discriminator } qui doit être unique, pour permettre + // l'ajout d'ami sans ambiguïté (voir uniqueDiscriminator dans + // auth.service.js et la recherche exacte dans friend.controller.js). + // Optionnel au niveau schéma (comptes antérieurs à cette fonctionnalité, + // backfillés lazily — voir user.service.js → getUserProfile) : l'index + // unique ci-dessous n'agit que sur les documents qui en ont déjà un. + discriminator: { type: String, match: /^\d{4}$/ }, email: { type: String, required: [true, "L'e-mail est obligatoire"], @@ -162,6 +172,13 @@ const UserSchema = new mongoose.Schema( // local (LOCAL_TROPHY_CATALOG) — voir user.service.js → updateShowcase. showcasedAchievements: { type: [String], default: [] }, + // ── Records d'exercices mis en avant (Section III) ──────────────────────── + // Noms d'exercices (max 6, contrôlé par Joi côté validateur) choisis par + // l'utilisateur pour son profil — remplace l'ancien top-5 automatique par + // poids max, affiché identiquement sur son propre profil et son profil + // public vu par ses amis (voir getFriendProfile dans friend.controller.js). + showcasedRecords: { type: [String], default: [] }, + // ── Cadre de profil équipé ───────────────────────────────────────────────── // Synchronisé depuis le choix local (useAvatarFrame.js) pour que les amis // voient le même cadre sur le profil public. shapeId/colorId sont des clés @@ -180,4 +197,18 @@ const UserSchema = new mongoose.Schema( // referralCode: index unique + sparse déclaré inline (options du champ) // sparse = tolérance aux anciens documents V1 sans code +// Combo { pseudo, discriminator } unique — insensible à la casse (collation). +// partialFilterExpression : n'applique la contrainte qu'aux documents qui ONT +// déjà un discriminator, pour ne jamais bloquer les comptes pré-migration +// (potentiellement plusieurs pseudos identiques sans discriminator) tant +// qu'ils n'ont pas été backfillés (voir getUserProfile / scripts/backfillDiscriminators.js). +UserSchema.index( + { pseudo: 1, discriminator: 1 }, + { + unique: true, + collation: { locale: "en", strength: 2 }, + partialFilterExpression: { discriminator: { $type: "string" } }, + } +); + module.exports = mongoose.model("User", UserSchema); diff --git a/back/models/WeightHistory.js b/back/models/WeightHistory.js new file mode 100644 index 0000000..d75883f --- /dev/null +++ b/back/models/WeightHistory.js @@ -0,0 +1,36 @@ +const mongoose = require("mongoose"); + +// ----------------------------------------------------- +// Modèle "WeightHistory" +// ----------------------------------------------------- +// Historique des pesées d'un utilisateur (Section VI — Suivi de poids). +// Une entrée par pesée enregistrée ; `user.poids` (User.js) reste le poids +// COURANT affiché ailleurs dans l'app, mis à jour en même temps qu'une +// entrée est créée ici (voir weight.controller.js → logWeight). +// ----------------------------------------------------- + +const WeightHistorySchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + weight: { + type: Number, + required: true, + min: 20, + max: 400, + }, + date: { + type: Date, + default: Date.now, + }, + }, + { timestamps: true } +); + +// Tri chronologique par utilisateur — requête la plus fréquente (courbe de poids). +WeightHistorySchema.index({ user: 1, date: 1 }); + +module.exports = mongoose.model("WeightHistory", WeightHistorySchema); diff --git a/back/routes/debug.routes.js b/back/routes/debug.routes.js index e90fe19..e21f47e 100644 --- a/back/routes/debug.routes.js +++ b/back/routes/debug.routes.js @@ -20,4 +20,13 @@ router.post('/godmode/simulate-referral', debug.simulateReferral); router.post('/godmode/simulate-birthday', debug.simulateBirthday); router.post('/godmode/mock-social', debug.mockSocial); +// ── God Mode : Vague 1 (groupe, météo, activité, Hall of Shame, secouer) ──── +router.post('/godmode/simulate-group', debug.simulateGroup); +router.post('/godmode/simulate-activity-event', debug.simulateActivityEvent); +router.post('/godmode/simulate-streak-break', debug.simulateStreakBreak); +router.post('/godmode/simulate-shake-self', debug.simulateShakeSelf); + +// ── God Mode : Vague 2 (tag Discord, ajout d'ami) ──────────────────────────── +router.post('/godmode/simulate-searchable-friend', debug.simulateSearchableFriend); + module.exports = router; diff --git a/back/routes/exercise.routes.js b/back/routes/exercise.routes.js index 32e20e6..d5a6fbe 100644 --- a/back/routes/exercise.routes.js +++ b/back/routes/exercise.routes.js @@ -16,6 +16,10 @@ router.post("/", auth, validate(exerciseRecordSchema), exerciseController.addRec // Classement par exercice au sein du réseau d'amis (?exercise=Nom) router.get("/leaderboard", auth, exerciseController.getExerciseLeaderboard); +// Tous mes records (un par exercice déjà pratiqué) — alimente le sélecteur +// "mettre en avant jusqu'à 6 records" du profil +router.get("/my-records", auth, exerciseController.getMyRecords); + // Obtenir l'historique de progression d'un exercice précis par son nom router.get("/history/:name", auth, exerciseController.getExerciseHistory); diff --git a/back/routes/friend.routes.js b/back/routes/friend.routes.js index a899f52..8cd81ae 100644 --- a/back/routes/friend.routes.js +++ b/back/routes/friend.routes.js @@ -11,7 +11,11 @@ router.use(auth); // ── Demandes ────────────────────────────────────────────────────────────────── router.post('/request', friend.sendFriendRequest); // Envoyer une demande router.put('/accept/:requestId', friend.acceptFriendRequest); // Accepter -router.put('/decline/:requestId', friend.declineFriendRequest); // Refuser +router.put('/decline/:requestId', friend.declineFriendRequest); // Refuser (reçue) +router.delete('/request/:requestId', friend.cancelFriendRequest); // Annuler (envoyée) + +// ── Rupture d'amitié ──────────────────────────────────────────────────────────── +router.delete('/:friendshipId', friend.removeFriend); // Retirer un ami // ── Consultation ────────────────────────────────────────────────────────────── router.get('/list', friend.getFriendsList); // Tous mes amis (accepted) diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 8a2dc29..6b0a22c 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const userController = require("../controllers/user.controller"); const auth = require("../middleware/auth.middleware"); const validate = require("../middleware/validate.middleware"); -const { updateProfile, updateFrame, updateShowcase, registerPushToken } = require("../validators/user.validator"); +const { updateProfile, updateFrame, updateShowcase, updateRecordsShowcase, registerPushToken } = require("../validators/user.validator"); /** * ROUTES UTILISATEURS PROTEGEES @@ -22,6 +22,9 @@ router.put("/me/frame", auth, validate(updateFrame), userController.updateFrame) // Mettre à jour la vitrine de trophées mis en avant (max 3) router.put("/me/showcase", auth, validate(updateShowcase), userController.updateShowcase); +// Mettre à jour les records d'exercices mis en avant (max 6) +router.put("/me/records-showcase", auth, validate(updateRecordsShowcase), userController.updateRecordsShowcase); + // Enregistrer (ou effacer) le token Expo Push de l'appareil courant router.put("/me/push-token", auth, validate(registerPushToken), userController.registerPushToken); diff --git a/back/routes/weight.routes.js b/back/routes/weight.routes.js new file mode 100644 index 0000000..c0d76ad --- /dev/null +++ b/back/routes/weight.routes.js @@ -0,0 +1,15 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth.middleware'); +const validate = require('../middleware/validate.middleware'); +const weight = require('../controllers/weight.controller'); +const { logWeight } = require('../validators/weight.validator'); + +router.use(auth); + +router.get('/history', weight.getWeightHistory); +router.post('/', validate(logWeight), weight.logWeight); + +module.exports = router; diff --git a/back/scripts/backfillDiscriminators.js b/back/scripts/backfillDiscriminators.js new file mode 100644 index 0000000..ebbf684 --- /dev/null +++ b/back/scripts/backfillDiscriminators.js @@ -0,0 +1,41 @@ +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// Script de migration : attribue un discriminator ("#1234") à tous les +// comptes créés avant le système de tag Discord (Section III). +// +// Non-destructif et idempotent : ne touche qu'aux documents sans +// discriminator (les nouveaux comptes en reçoivent déjà un à l'inscription, +// et le profil en génère un lazily au premier GET /users/me — voir +// user.service.js). Ce script est un complément pour un backfill immédiat +// en masse plutôt que d'attendre que chaque utilisateur se reconnecte. +// +// Usage : node scripts/backfillDiscriminators.js +// ───────────────────────────────────────────────────────────────────────────── + +require('dotenv').config(); +const mongoose = require('mongoose'); +const User = require('../models/User'); +const { uniqueDiscriminator } = require('../services/auth.service'); + +async function run() { + await mongoose.connect(process.env.MONGO_URI); + + const users = await User.find({ discriminator: { $exists: false } }).select('_id pseudo'); + console.log(`[backfillDiscriminators] ${users.length} compte(s) à migrer.`); + + let migrated = 0; + for (const user of users) { + user.discriminator = await uniqueDiscriminator(user.pseudo); + await user.save(); + migrated += 1; + } + + console.log(`[backfillDiscriminators] ${migrated} compte(s) migré(s) avec succès.`); + await mongoose.connection.close(); +} + +run().catch((err) => { + console.error('[backfillDiscriminators] Échec :', err); + process.exit(1); +}); diff --git a/back/services/auth.service.js b/back/services/auth.service.js index daee257..400bb7f 100644 --- a/back/services/auth.service.js +++ b/back/services/auth.service.js @@ -39,6 +39,26 @@ async function uniqueReferralCode() { return `ATH-${Date.now().toString(36).toUpperCase().slice(-6)}`; } +// Tag numérique façon Discord (Section III) : "Pseudo#1234". Contrairement au +// referralCode (unique globalement), le discriminator n'a besoin d'être +// unique QUE combiné au pseudo — 9000 combinaisons par pseudo suffisent +// largement avant toute collision réelle. +function generateDiscriminator() { + return String(Math.floor(1000 + Math.random() * 9000)); +} + +async function uniqueDiscriminator(pseudo) { + for (let attempt = 0; attempt < 20; attempt++) { + const discriminator = generateDiscriminator(); + const taken = await User.exists({ pseudo, discriminator }) + .collation({ locale: "en", strength: 2 }); + if (!taken) return discriminator; + } + // Repli quasi-impossible (20 tentatives sur 9000 combinaisons) : dernier + // recours horodaté, tronqué à 4 chiffres. + return String(Date.now()).slice(-4); +} + function makeToken(userId) { return jwt.sign({ id: userId }, config.jwtSecret, { expiresIn: config.jwtExpires }); } @@ -91,6 +111,7 @@ class AuthService { codeExpires: new Date(Date.now() + CODE_TTL_VERIFY), verifyAttempts: 0, referralCode: await uniqueReferralCode(), + discriminator: await uniqueDiscriminator(pseudo), ...(referrer && { referredBy: referrer._id, // Récompenses de bienvenue du filleul, directement à la création @@ -167,6 +188,7 @@ class AuthService { user: { id: user._id, pseudo: user.pseudo || user.name, + discriminator: user.discriminator, email: user.email, level: user.level, }, @@ -216,7 +238,7 @@ class AuthService { const token = makeToken(user._id); return { token, - user: { id: user._id, pseudo: user.pseudo || user.name, email: user.email }, + user: { id: user._id, pseudo: user.pseudo || user.name, discriminator: user.discriminator, email: user.email }, }; } @@ -325,3 +347,4 @@ class AuthService { module.exports = new AuthService(); // Réutilisé par user.service (génération lazy pour les comptes existants) module.exports.uniqueReferralCode = uniqueReferralCode; +module.exports.uniqueDiscriminator = uniqueDiscriminator; diff --git a/back/services/user.service.js b/back/services/user.service.js index 9b1ea14..7a01105 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -24,6 +24,14 @@ class UserService { await user.save(); } + // Génération lazy du tag Discord (Section III) pour les comptes créés + // avant cette fonctionnalité — même idiome que referralCode ci-dessus. + if (!user.discriminator) { + const { uniqueDiscriminator } = require("./auth.service"); + user.discriminator = await uniqueDiscriminator(user.pseudo); + await user.save(); + } + // Présence : getMe est appelé à chaque focus d'écran côté front, ce qui // en fait un signal "activité récente" fiable pour la Météo des séances // (statut 🔥 Prêt) sans avoir besoin d'un endpoint de heartbeat dédié. @@ -90,6 +98,31 @@ class UserService { return updatedUser; } + /** + * Met à jour atomiquement les records d'exercices mis en avant (max 6). + * Seuls des exercices pour lesquels l'utilisateur a AU MOINS un + * ExerciseRecord sont acceptés — impossible de mettre en avant un + * exercice jamais pratiqué. L'ordre saisi est conservé (ordre d'affichage). + * @param {string} userId + * @param {string[]} exerciseNames + */ + async updateRecordsShowcase(userId, exerciseNames) { + const ExerciseRecord = require('../models/ExerciseRecord'); + + const dedup = [...new Set(exerciseNames)].slice(0, 6); + const owned = await ExerciseRecord.distinct('exerciceNom', { user: userId, exerciceNom: { $in: dedup } }); + const ownedSet = new Set(owned); + const validNames = dedup.filter((n) => ownedSet.has(n)); + + const updatedUser = await User.findByIdAndUpdate( + userId, + { $set: { showcasedRecords: validNames } }, + { new: true, runValidators: true }, + ).select('showcasedRecords'); + if (!updatedUser) throw new Error('Utilisateur non trouvé.'); + return updatedUser; + } + /** * Met à jour atomiquement le cadre de profil équipé (forme + couleur). * @param {string} userId diff --git a/back/tests/debug.test.js b/back/tests/debug.test.js index 6cf19e8..7889acb 100644 --- a/back/tests/debug.test.js +++ b/back/tests/debug.test.js @@ -282,3 +282,204 @@ describe('POST /api/debug/godmode/mock-social — outil dev (génère un faux r } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// God Mode — Vague 1 (groupe, météo, activité, Hall of Shame, secouer) +// ───────────────────────────────────────────────────────────────────────────── + +describe('God Mode — outils de test Vague 1', () => { + const StreakGroup = require('../models/StreakGroup'); + const ActivityEvent = require('../models/ActivityEvent'); + const Workout = require('../models/Workout'); + let alice; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + await StreakGroup.deleteMany({}); + await ActivityEvent.deleteMany({}); + await Workout.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + await StreakGroup.deleteMany({}); + await ActivityEvent.deleteMany({}); + await Workout.deleteMany({}); + alice = await createAndLoginUser('AliceFit', 'alice@athly.fr'); + }); + + describe('POST /api/debug/godmode/simulate-group', () => { + it('✅ Crée un groupe avec 3 coéquipiers (ready/active/done) et une streak', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(201); + expect(res.body.groupId).toBeTruthy(); + + const groupRes = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(groupRes.body.group.members).toHaveLength(4); // moi + 3 coéquipiers + expect(groupRes.body.group.currentStreak).toBe(5); + + const statuses = groupRes.body.group.members + .filter((m) => m.pseudo !== 'AliceFit') + .map((m) => m.weatherStatus) + .sort(); + expect(statuses).toEqual(['active', 'done', 'ready']); + }); + + it('🔁 Idempotent : rejouer ne crée pas de doublons de coéquipiers', async () => { + await request(app).post('/api/debug/godmode/simulate-group').set('Authorization', `Bearer ${alice.token}`); + await request(app).post('/api/debug/godmode/simulate-group').set('Authorization', `Bearer ${alice.token}`); + + const groups = await StreakGroup.find({ members: alice.userId }); + expect(groups).toHaveLength(1); + expect(groups[0].members).toHaveLength(4); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/simulate-group'); + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/debug/godmode/simulate-activity-event', () => { + it("❌ 400 si l'utilisateur n'a pas de groupe", async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-activity-event') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(400); + }); + + it('✅ Publie un événement au nom d\'un coéquipier, visible dans le feed', async () => { + await request(app).post('/api/debug/godmode/simulate-group').set('Authorization', `Bearer ${alice.token}`); + + const res = await request(app) + .post('/api/debug/godmode/simulate-activity-event') + .set('Authorization', `Bearer ${alice.token}`) + .send({ type: 'chest_legendary' }); + + expect(res.statusCode).toBe(201); + expect(res.body.eventId).toBeTruthy(); + + const feedRes = await request(app) + .get('/api/activity/feed') + .set('Authorization', `Bearer ${alice.token}`); + expect(feedRes.body.events).toHaveLength(1); + expect(feedRes.body.events[0].actor.pseudo).not.toBe('AliceFit'); + }); + }); + + describe('POST /api/debug/godmode/simulate-streak-break', () => { + it("❌ 400 si l'utilisateur n'a pas de groupe", async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-streak-break') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(400); + }); + + it('✅ Déclenche le Hall of Shame au prochain getMyGroup', async () => { + await request(app).post('/api/debug/godmode/simulate-group').set('Authorization', `Bearer ${alice.token}`); + + const res = await request(app) + .post('/api/debug/godmode/simulate-streak-break') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(200); + + const groupRes = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(groupRes.body.group.currentStreak).toBe(0); + expect(groupRes.body.group.shameBreakers.length).toBeGreaterThan(0); + }); + }); + + describe('POST /api/debug/godmode/simulate-shake-self', () => { + it('❌ 400 si aucun token push enregistré', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-shake-self') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(400); + }); + + it('✅ 200 avec pushed=false si le token est enregistré mais invalide', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { pushToken: 'not-a-real-expo-token' } }); + + const res = await request(app) + .post('/api/debug/godmode/simulate-shake-self') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.pushed).toBe(false); + }); + }); + + describe('POST /api/debug/godmode/simulate-searchable-friend', () => { + it('✅ Crée un compte de test cherchable (relationStatus none)', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-searchable-friend') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(201); + expect(res.body.tag).toMatch(/^TestAmi#\d{4}$/); + + const searchRes = await request(app) + .get(`/api/friends/search?q=${encodeURIComponent(res.body.tag)}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(searchRes.body.count).toBe(1); + expect(searchRes.body.results[0].relationStatus).toBe('none'); + }); + + it('🔁 Idempotent : rejouer renvoie le même tag tant qu\'aucune relation n\'existe', async () => { + const first = await request(app) + .post('/api/debug/godmode/simulate-searchable-friend') + .set('Authorization', `Bearer ${alice.token}`); + const second = await request(app) + .post('/api/debug/godmode/simulate-searchable-friend') + .set('Authorization', `Bearer ${alice.token}`); + + expect(second.body.tag).toBe(first.body.tag); + + const count = await User.countDocuments({ pseudo: 'TestAmi' }); + expect(count).toBe(1); + }); + + it('✅ Recrée un compte frais si le précédent est devenu ami', async () => { + const first = await request(app) + .post('/api/debug/godmode/simulate-searchable-friend') + .set('Authorization', `Bearer ${alice.token}`); + + const searchRes = await request(app) + .get(`/api/friends/search?q=${encodeURIComponent(first.body.tag)}`) + .set('Authorization', `Bearer ${alice.token}`); + const testAmiId = searchRes.body.results[0].user._id; + + await Friendship.create({ requester: alice.userId, recipient: testAmiId, status: 'accepted' }); + + const second = await request(app) + .post('/api/debug/godmode/simulate-searchable-friend') + .set('Authorization', `Bearer ${alice.token}`); + + expect(second.statusCode).toBe(201); // nouveau compte créé + expect(second.body.tag).not.toBe(first.body.tag); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/simulate-searchable-friend'); + expect(res.statusCode).toBe(401); + }); + }); +}); diff --git a/back/tests/discriminator.test.js b/back/tests/discriminator.test.js new file mode 100644 index 0000000..abfc313 --- /dev/null +++ b/back/tests/discriminator.test.js @@ -0,0 +1,138 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const { uniqueDiscriminator } = require('../services/auth.service'); + +async function createAndLoginUser(pseudo, email) { + await request(app).post('/api/auth/register').send({ pseudo, email, password: 'Password123!' }); + await User.updateOne({ email }, { isVerified: true }); + const loginRes = await request(app).post('/api/auth/login').send({ email, password: 'Password123!' }); + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +describe('Tag numérique façon Discord — Section III', () => { + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + }); + + describe('Génération à l\'inscription', () => { + it('✅ Un nouveau compte reçoit un discriminator à 4 chiffres', async () => { + const alice = await createAndLoginUser('AliceTag', 'alice.tag@athly.fr'); + const user = await User.findById(alice.userId).select('discriminator'); + expect(user.discriminator).toMatch(/^\d{4}$/); + }); + + it('✅ Deux comptes avec le même pseudo reçoivent des discriminators différents', async () => { + const a = await createAndLoginUser('SameName', 'same1@athly.fr'); + const b = await createAndLoginUser('SameName', 'same2@athly.fr'); + + const userA = await User.findById(a.userId).select('discriminator'); + const userB = await User.findById(b.userId).select('discriminator'); + + expect(userA.discriminator).not.toBe(userB.discriminator); + }); + + it('✅ login renvoie le discriminator (utilisable immédiatement pour afficher le tag)', async () => { + const fresh = await createAndLoginUser('FreshUser', 'fresh@athly.fr'); + const loginRes = await request(app) + .post('/api/auth/login') + .send({ email: 'fresh@athly.fr', password: 'Password123!' }); + + expect(loginRes.body.user.discriminator).toMatch(/^\d{4}$/); + expect(fresh.userId).toBeTruthy(); + }); + }); + + describe('uniqueDiscriminator — collision handling', () => { + it('✅ Ne réattribue jamais un discriminator déjà pris pour le même pseudo', async () => { + await User.create({ + pseudo: 'CollisionTest', + email: 'collision@athly.fr', + password: 'hashed', + isVerified: true, + discriminator: '1234', + }); + + // Génère 30 discriminators pour ce même pseudo : jamais "1234" + for (let i = 0; i < 30; i++) { + const d = await uniqueDiscriminator('CollisionTest'); + expect(d).not.toBe('1234'); + } + }); + + it('✅ Le même discriminator EST réutilisable pour un pseudo différent', async () => { + await User.create({ + pseudo: 'PseudoA', + email: 'pseudoa@athly.fr', + password: 'hashed', + isVerified: true, + discriminator: '5555', + }); + + const created = await User.create({ + pseudo: 'PseudoB', + email: 'pseudob@athly.fr', + password: 'hashed', + isVerified: true, + discriminator: '5555', + }); + + expect(created.discriminator).toBe('5555'); + }); + }); + + describe('Backfill lazy — GET /users/me', () => { + it('✅ Un compte sans discriminator (pré-migration) en reçoit un au premier GET /users/me', async () => { + const alice = await createAndLoginUser('LegacyUser', 'legacy@athly.fr'); + // Simule un compte créé avant la fonctionnalité + await User.updateOne({ _id: alice.userId }, { $unset: { discriminator: '' } }); + + const before = await User.findById(alice.userId).select('discriminator'); + expect(before.discriminator).toBeUndefined(); + + const res = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.user.discriminator).toMatch(/^\d{4}$/); + + const after = await User.findById(alice.userId).select('discriminator'); + expect(after.discriminator).toMatch(/^\d{4}$/); + }); + }); + + describe('Contrainte d\'unicité { pseudo, discriminator }', () => { + it('❌ Rejette la création directe d\'un doublon exact (même pseudo + discriminator)', async () => { + await User.create({ + pseudo: 'DupTest', + email: 'dup1@athly.fr', + password: 'hashed', + isVerified: true, + discriminator: '4242', + }); + + await expect(User.create({ + pseudo: 'DupTest', + email: 'dup2@athly.fr', + password: 'hashed', + isVerified: true, + discriminator: '4242', + })).rejects.toThrow(); + }); + }); +}); diff --git a/back/tests/groupStreak.test.js b/back/tests/groupStreak.test.js index 5708d78..7e8b6f2 100644 --- a/back/tests/groupStreak.test.js +++ b/back/tests/groupStreak.test.js @@ -289,6 +289,33 @@ describe("Streaks de Groupe & Niveaux d'Amitié Athly — V2", () => { expect(res.statusCode).toBe(403); }); + + it("❌ Impossible de secouer deux fois la même personne le même jour", async () => { + await request(app) + .post(`/api/groups/${groupId}/shake/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + const res = await request(app) + .post(`/api/groups/${groupId}/shake/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(422); + expect(res.body.success).toBe(false); + }); + + it("✅ getMyGroup renvoie shakenTodayByMe après une secousse", async () => { + await request(app) + .post(`/api/groups/${groupId}/shake/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + const res = await request(app) + .get('/api/groups/my-group') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.group.shakenTodayByMe).toEqual([bob.userId]); + // Historique brut jamais exposé au front (vie privée / poids de réponse) + expect(res.body.group.shakes).toBeUndefined(); + }); }); // ─────────────────────────────────────────────────────────────────────────── diff --git a/back/tests/recordsShowcase.test.js b/back/tests/recordsShowcase.test.js new file mode 100644 index 0000000..268693d --- /dev/null +++ b/back/tests/recordsShowcase.test.js @@ -0,0 +1,172 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const Workout = require('../models/Workout'); +const ExerciseRecord = require('../models/ExerciseRecord'); +const Friendship = require('../models/Friendship'); + +async function createAndLoginUser(pseudo, email) { + await request(app).post('/api/auth/register').send({ pseudo, email, password: 'Password123!' }); + await User.updateOne({ email }, { isVerified: true }); + const loginRes = await request(app).post('/api/auth/login').send({ email, password: 'Password123!' }); + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +async function addRecord(userId, exerciceNom, poids, repetitions = 5) { + const workout = await Workout.create({ user: userId, status: 'finished', date: new Date() }); + return ExerciseRecord.create({ user: userId, workout: workout._id, exerciceNom, series: [{ poids, repetitions }] }); +} + +describe('Records mis en avant sur le profil — Section III', () => { + let alice, bob; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await Workout.deleteMany({}); + await ExerciseRecord.deleteMany({}); + await Friendship.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Workout.deleteMany({}); + await ExerciseRecord.deleteMany({}); + await Friendship.deleteMany({}); + alice = await createAndLoginUser('AliceRecords', 'alice.records@athly.fr'); + bob = await createAndLoginUser('BobRecords', 'bob.records@athly.fr'); + }); + + describe('GET /api/exercises/my-records', () => { + it('✅ Renvoie un record par exercice pratiqué, trié par poids max décroissant', async () => { + await addRecord(alice.userId, 'Squat', 100); + await addRecord(alice.userId, 'Développé couché', 80); + await addRecord(alice.userId, 'Squat', 110); // meilleure série sur le même exercice + + const res = await request(app) + .get('/api/exercises/my-records') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.count).toBe(2); + expect(res.body.records[0]).toEqual({ exercice: 'Squat', maxPoids: 110, maxReps: 5 }); + }); + + it("✅ Tableau vide si aucun record", async () => { + const res = await request(app) + .get('/api/exercises/my-records') + .set('Authorization', `Bearer ${alice.token}`); + expect(res.body.records).toEqual([]); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).get('/api/exercises/my-records'); + expect(res.statusCode).toBe(401); + }); + }); + + describe('PUT /api/users/me/records-showcase', () => { + it('✅ Accepte un exercice pratiqué et le renvoie dans showcasedRecords', async () => { + await addRecord(alice.userId, 'Squat', 100); + + const res = await request(app) + .put('/api/users/me/records-showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ exerciseNames: ['Squat'] }); + + expect(res.statusCode).toBe(200); + expect(res.body.showcasedRecords).toEqual(['Squat']); + }); + + it("❌ Rejette silencieusement un exercice jamais pratiqué", async () => { + await addRecord(alice.userId, 'Squat', 100); + + const res = await request(app) + .put('/api/users/me/records-showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ exerciseNames: ['Squat', 'Jamais Fait'] }); + + expect(res.statusCode).toBe(200); + expect(res.body.showcasedRecords).toEqual(['Squat']); + }); + + it('✅ Conserve l\'ordre de sélection', async () => { + await addRecord(alice.userId, 'Squat', 100); + await addRecord(alice.userId, 'Développé couché', 80); + await addRecord(alice.userId, 'Tractions', 20); + + const res = await request(app) + .put('/api/users/me/records-showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ exerciseNames: ['Tractions', 'Squat', 'Développé couché'] }); + + expect(res.body.showcasedRecords).toEqual(['Tractions', 'Squat', 'Développé couché']); + }); + + it('❌ 400 si plus de 6 exercices', async () => { + const res = await request(app) + .put('/api/users/me/records-showcase') + .set('Authorization', `Bearer ${alice.token}`) + .send({ exerciseNames: ['A', 'B', 'C', 'D', 'E', 'F', 'G'] }); + expect(res.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).put('/api/users/me/records-showcase').send({ exerciseNames: [] }); + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/friends/profile/:friendId — records mis en avant', () => { + beforeEach(async () => { + await Friendship.create({ requester: alice.userId, recipient: bob.userId, status: 'accepted' }); + }); + + it("✅ Utilise showcasedRecords de l'ami (ordre préservé) quand configuré", async () => { + await addRecord(bob.userId, 'Squat', 150); + await addRecord(bob.userId, 'Développé couché', 100); + await addRecord(bob.userId, 'Tractions', 25); + + await request(app) + .put('/api/users/me/records-showcase') + .set('Authorization', `Bearer ${bob.token}`) + .send({ exerciseNames: ['Tractions', 'Développé couché'] }); // pas triés par poids + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.profile.records.map((r) => r.exercice)).toEqual(['Tractions', 'Développé couché']); + // Squat existe mais n'est pas dans la vitrine -> absent + expect(res.body.profile.records.find((r) => r.exercice === 'Squat')).toBeUndefined(); + }); + + it("✅ Repli sur le top 5 auto par poids si aucune vitrine configurée", async () => { + await addRecord(bob.userId, 'Squat', 150); + await addRecord(bob.userId, 'Développé couché', 100); + + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.profile.records.map((r) => r.exercice)).toEqual(['Squat', 'Développé couché']); + }); + + it("✅ Aucun record si l'ami n'a jamais rien enregistré", async () => { + const res = await request(app) + .get(`/api/friends/profile/${bob.userId}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(res.body.profile.records).toEqual([]); + }); + }); +}); diff --git a/back/tests/socialEngine.test.js b/back/tests/socialEngine.test.js index 9d8277c..c341701 100644 --- a/back/tests/socialEngine.test.js +++ b/back/tests/socialEngine.test.js @@ -31,6 +31,12 @@ async function makeFriends(a, b) { return Friendship.create({ requester: a, recipient: b, status: 'accepted' }); } +/** Construit le tag complet "Pseudo#1234" à partir de l'ID (recherche exacte). */ +async function tagFor(userId) { + const user = await User.findById(userId).select('pseudo discriminator'); + return `${user.pseudo}#${user.discriminator}`; +} + async function finishedWorkoutToday(userId) { return Workout.create({ user: userId, @@ -75,13 +81,14 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { // ─────────────────────────────────────────────────────────────────────────── // 1. Recherche d'utilisateurs // ─────────────────────────────────────────────────────────────────────────── - describe('GET /api/friends/search — searchUsers', () => { + describe('GET /api/friends/search — searchUsers (tag exact "Pseudo#1234")', () => { - it('✅ Trouve les pseudos correspondants avec le statut de relation', async () => { + it('✅ Trouve exactement le tag complet avec le statut de relation', async () => { await makeFriends(alice.userId, bob.userId); + const bobTag = await tagFor(bob.userId); const res = await request(app) - .get('/api/friends/search?q=bob') + .get(`/api/friends/search?q=${encodeURIComponent(bobTag)}`) .set('Authorization', `Bearer ${alice.token}`); expect(res.statusCode).toBe(200); @@ -92,35 +99,60 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { it("✅ relationStatus pending_sent/received selon le sens de la demande", async () => { await Friendship.create({ requester: alice.userId, recipient: carol.userId, status: 'pending' }); + const carolTag = await tagFor(carol.userId); + const aliceTag = await tagFor(alice.userId); const fromAlice = await request(app) - .get('/api/friends/search?q=carol') + .get(`/api/friends/search?q=${encodeURIComponent(carolTag)}`) .set('Authorization', `Bearer ${alice.token}`); expect(fromAlice.body.results[0].relationStatus).toBe('pending_sent'); const fromCarol = await request(app) - .get('/api/friends/search?q=alice') + .get(`/api/friends/search?q=${encodeURIComponent(aliceTag)}`) .set('Authorization', `Bearer ${carol.token}`); expect(fromCarol.body.results[0].relationStatus).toBe('pending_received'); }); + it('✅ Insensible à la casse sur le pseudo, discriminator exact', async () => { + const bobTag = await tagFor(bob.userId); + const upperTag = bobTag.toUpperCase(); + + const res = await request(app) + .get(`/api/friends/search?q=${encodeURIComponent(upperTag)}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.count).toBe(1); + expect(res.body.results[0].user.pseudo).toBe('BobMuscle'); + }); + it('✅ Ne se trouve jamais soi-même dans les résultats', async () => { + const aliceTag = await tagFor(alice.userId); const res = await request(app) - .get('/api/friends/search?q=alice') + .get(`/api/friends/search?q=${encodeURIComponent(aliceTag)}`) .set('Authorization', `Bearer ${alice.token}`); - expect(res.body.results.every((r) => r.user.pseudo !== 'AliceFit')).toBe(true); + expect(res.body.count).toBe(0); }); - it('❌ 400 si la recherche fait moins de 2 caractères', async () => { + it('✅ Renvoie 0 résultat si le discriminator ne correspond pas', async () => { + const bob2 = await User.findById(bob.userId).select('pseudo discriminator'); + const wrongDiscriminator = bob2.discriminator === '0000' ? '1111' : '0000'; + + const res = await request(app) + .get(`/api/friends/search?q=${encodeURIComponent(`${bob2.pseudo}#${wrongDiscriminator}`)}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(res.body.count).toBe(0); + }); + + it('❌ 400 si le format "Pseudo#1234" complet n\'est pas respecté', async () => { const res = await request(app) - .get('/api/friends/search?q=a') + .get('/api/friends/search?q=bob') .set('Authorization', `Bearer ${alice.token}`); expect(res.statusCode).toBe(400); }); - it('🔒 Les caractères regex sont neutralisés (pas d\'injection de pattern)', async () => { + it('🔒 Les caractères regex du pseudo sont traités littéralement (pas d\'injection)', async () => { const res = await request(app) - .get('/api/friends/search?q=' + encodeURIComponent('.*')) + .get('/api/friends/search?q=' + encodeURIComponent('.*#1234')) .set('Authorization', `Bearer ${alice.token}`); expect(res.statusCode).toBe(200); expect(res.body.count).toBe(0); // ".*" littéral ne matche aucun pseudo @@ -242,6 +274,113 @@ describe('Moteur RPG & Social Athly — Briques II, III, IV', () => { }); }); + describe('GET /api/friends/pending — demandes reçues ET envoyées', () => { + it('✅ Distingue les demandes reçues des demandes envoyées', async () => { + // Alice envoie à Bob (sent, côté Alice) ; Carol envoie à Alice (requests, côté Alice) + await Friendship.create({ requester: alice.userId, recipient: bob.userId, status: 'pending' }); + await Friendship.create({ requester: carol.userId, recipient: alice.userId, status: 'pending' }); + + const res = await request(app) + .get('/api/friends/pending') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.requests[0].requester.pseudo).toBe('CarolGains'); + expect(res.body.sentCount).toBe(1); + expect(res.body.sent[0].recipient.pseudo).toBe('BobMuscle'); + }); + }); + + describe("DELETE /api/friends/request/:requestId — cancelFriendRequest", () => { + it("✅ L'auteur de la demande peut l'annuler", async () => { + const f = await Friendship.create({ requester: alice.userId, recipient: bob.userId, status: 'pending' }); + + const res = await request(app) + .delete(`/api/friends/request/${f._id}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(await Friendship.findById(f._id)).toBeNull(); + }); + + it("❌ Le destinataire ne peut pas annuler (seul refuser)", async () => { + const f = await Friendship.create({ requester: alice.userId, recipient: bob.userId, status: 'pending' }); + + const res = await request(app) + .delete(`/api/friends/request/${f._id}`) + .set('Authorization', `Bearer ${bob.token}`); + + expect(res.statusCode).toBe(403); + }); + + it('❌ 422 si la demande a déjà été acceptée', async () => { + const f = await Friendship.create({ requester: alice.userId, recipient: bob.userId, status: 'accepted' }); + + const res = await request(app) + .delete(`/api/friends/request/${f._id}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(422); + }); + + it('❌ 401 sans token', async () => { + const f = await Friendship.create({ requester: alice.userId, recipient: bob.userId, status: 'pending' }); + const res = await request(app).delete(`/api/friends/request/${f._id}`); + expect(res.statusCode).toBe(401); + }); + }); + + describe('DELETE /api/friends/:friendshipId — removeFriend', () => { + it("✅ Le requester peut retirer l'ami", async () => { + const f = await makeFriends(alice.userId, bob.userId); + + const res = await request(app) + .delete(`/api/friends/${f._id}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(await Friendship.findById(f._id)).toBeNull(); + }); + + it("✅ Le recipient peut aussi retirer l'ami", async () => { + const f = await makeFriends(alice.userId, bob.userId); + + const res = await request(app) + .delete(`/api/friends/${f._id}`) + .set('Authorization', `Bearer ${bob.token}`); + + expect(res.statusCode).toBe(200); + expect(await Friendship.findById(f._id)).toBeNull(); + }); + + it("❌ 403 si l'appelant ne fait pas partie de l'amitié", async () => { + const f = await makeFriends(alice.userId, bob.userId); + + const res = await request(app) + .delete(`/api/friends/${f._id}`) + .set('Authorization', `Bearer ${carol.token}`); + + expect(res.statusCode).toBe(403); + }); + + it("❌ 422 si la relation n'est pas encore acceptée (pending)", async () => { + const f = await Friendship.create({ requester: alice.userId, recipient: bob.userId, status: 'pending' }); + + const res = await request(app) + .delete(`/api/friends/${f._id}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(422); + }); + + it('❌ 401 sans token', async () => { + const f = await makeFriends(alice.userId, bob.userId); + const res = await request(app).delete(`/api/friends/${f._id}`); + expect(res.statusCode).toBe(401); + }); + }); + // ─────────────────────────────────────────────────────────────────────────── // 4. Coffres à l'effort (minutes de séance cumulées) // ─────────────────────────────────────────────────────────────────────────── diff --git a/back/tests/weight.test.js b/back/tests/weight.test.js new file mode 100644 index 0000000..3d61e7d --- /dev/null +++ b/back/tests/weight.test.js @@ -0,0 +1,132 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const WeightHistory = require('../models/WeightHistory'); + +async function createAndLoginUser(pseudo, email) { + await request(app).post('/api/auth/register').send({ pseudo, email, password: 'Password123!' }); + await User.updateOne({ email }, { isVerified: true }); + const loginRes = await request(app).post('/api/auth/login').send({ email, password: 'Password123!' }); + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +describe('Suivi de poids — Section VI', () => { + let alice; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await WeightHistory.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await WeightHistory.deleteMany({}); + alice = await createAndLoginUser('AliceWeight', 'alice.weight@athly.fr'); + }); + + describe('POST /api/weight — logWeight', () => { + it('✅ Enregistre une pesée et met à jour user.poids', async () => { + const res = await request(app) + .post('/api/weight') + .set('Authorization', `Bearer ${alice.token}`) + .send({ weight: 78.5 }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.entry.weight).toBe(78.5); + + const user = await User.findById(alice.userId).select('poids'); + expect(user.poids).toBe(78.5); + + const rows = await WeightHistory.find({ user: alice.userId }); + expect(rows).toHaveLength(1); + }); + + it("✅ Une pesée rétroactive (date passée) ne met PAS à jour user.poids si une entrée plus récente existe", async () => { + await request(app) + .post('/api/weight') + .set('Authorization', `Bearer ${alice.token}`) + .send({ weight: 80 }); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + await request(app) + .post('/api/weight') + .set('Authorization', `Bearer ${alice.token}`) + .send({ weight: 82, date: yesterday.toISOString() }); + + const user = await User.findById(alice.userId).select('poids'); + expect(user.poids).toBe(80); // pas écrasé par la pesée rétroactive + }); + + it('❌ 400 si weight hors bornes (20–400)', async () => { + const res = await request(app) + .post('/api/weight') + .set('Authorization', `Bearer ${alice.token}`) + .send({ weight: 5 }); + + expect(res.statusCode).toBe(400); + }); + + it('❌ 400 si weight manquant', async () => { + const res = await request(app) + .post('/api/weight') + .set('Authorization', `Bearer ${alice.token}`) + .send({}); + + expect(res.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/weight').send({ weight: 75 }); + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/weight/history — getWeightHistory', () => { + it('✅ Retourne les pesées triées chronologiquement', async () => { + const dayAgo = new Date(); + dayAgo.setDate(dayAgo.getDate() - 2); + + await WeightHistory.create({ user: alice.userId, weight: 82, date: dayAgo }); + await WeightHistory.create({ user: alice.userId, weight: 80, date: new Date() }); + + const res = await request(app) + .get('/api/weight/history') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.history).toHaveLength(2); + expect(res.body.history[0].weight).toBe(82); // plus ancien en premier + expect(res.body.history[1].weight).toBe(80); + }); + + it("✅ Ne renvoie que l'historique de l'utilisateur connecté", async () => { + const bob = await createAndLoginUser('BobWeight', 'bob.weight@athly.fr'); + await WeightHistory.create({ user: bob.userId, weight: 90, date: new Date() }); + + const res = await request(app) + .get('/api/weight/history') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.history).toHaveLength(0); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).get('/api/weight/history'); + expect(res.statusCode).toBe(401); + }); + }); +}); diff --git a/back/validators/user.validator.js b/back/validators/user.validator.js index 6982739..1703eff 100644 --- a/back/validators/user.validator.js +++ b/back/validators/user.validator.js @@ -27,6 +27,10 @@ const userSchemas = { achievementIds: Joi.array().items(Joi.string().max(60)).max(3).required(), }), + updateRecordsShowcase: Joi.object({ + exerciseNames: Joi.array().items(Joi.string().max(80)).max(6).required(), + }), + registerPushToken: Joi.object({ // null explicite = désenregistrement (permissions révoquées côté client) pushToken: Joi.string().max(200).allow(null).required(), diff --git a/back/validators/weight.validator.js b/back/validators/weight.validator.js new file mode 100644 index 0000000..219f281 --- /dev/null +++ b/back/validators/weight.validator.js @@ -0,0 +1,10 @@ +const Joi = require('joi'); + +const weightSchemas = { + logWeight: Joi.object({ + weight: Joi.number().min(20).max(400).required(), + date: Joi.date().iso().max('now').optional(), + }), +}; + +module.exports = weightSchemas; diff --git a/front/src/components/common/WeightEntryModal.js b/front/src/components/common/WeightEntryModal.js new file mode 100644 index 0000000..80c61cb --- /dev/null +++ b/front/src/components/common/WeightEntryModal.js @@ -0,0 +1,177 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import { logWeight } from '../../services/weight.service'; + +// ─── WeightEntryModal ───────────────────────────────────────────────────────── +// Saisie rapide d'une pesée (Section VI) — modale custom partagée entre +// l'écran Statistiques (bouton "+ Ajouter une pesée") et le rappel +// hebdomadaire (WeightReminderModal → "Entrer mon poids"). +// +// Props : +// visible bool +// onClose () => void +// onSaved (weight: number) => void — appelé après enregistrement réussi + +export default function WeightEntryModal({ visible, onClose, onSaved }) { + const [value, setValue] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (visible) { setValue(''); setError(''); setSaving(false); } + }, [visible]); + + const handleSave = async () => { + const weight = parseFloat(value.replace(',', '.')); + if (!value || isNaN(weight) || weight < 20 || weight > 400) { + setError('Entre un poids valide (20–400 kg).'); + return; + } + setSaving(true); + setError(''); + try { + await logWeight(weight); + onSaved?.(weight); + onClose?.(); + } catch (e) { + setError(e?.data?.message || 'Impossible d\'enregistrer ta pesée.'); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + + + Entrer mon poids + Renseigne ta pesée du jour pour garder ton suivi à jour. + + + + kg + + {!!error && {error}} + + + {saving + ? + : Enregistrer} + + + + Annuler + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 28, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + iconWrap: { + width: 60, + height: 60, + borderRadius: 18, + backgroundColor: `${Colors.primary}1A`, + borderWidth: 1, + borderColor: `${Colors.primary}45`, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 18, + }, + title: { + color: Colors.textPrimary, + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 8, + textAlign: 'center', + }, + body: { + color: Colors.textSecondary, + fontSize: 13, + lineHeight: 19, + textAlign: 'center', + marginBottom: 20, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + borderWidth: 1, + borderColor: Colors.borderSubtle, + borderRadius: 13, + backgroundColor: 'rgba(255,255,255,0.04)', + paddingHorizontal: 16, + height: 54, + }, + input: { + flex: 1, + color: Colors.textPrimary, + fontSize: 22, + fontWeight: '800', + }, + unit: { color: Colors.textMuted, fontSize: 15, fontWeight: '700' }, + error: { color: Colors.error, fontSize: 12, marginTop: 8, alignSelf: 'flex-start' }, + saveBtn: { + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Colors.primary, + marginTop: 20, + marginBottom: 10, + shadowColor: Colors.primary, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + saveBtnDisabled: { opacity: 0.6 }, + saveBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + cancelBtn: { width: '100%', height: 44, justifyContent: 'center', alignItems: 'center' }, + cancelTxt: { color: Colors.textMuted, fontSize: 14, fontWeight: '500' }, +}); diff --git a/front/src/components/profile/BirthdayCelebration.js b/front/src/components/profile/BirthdayCelebration.js index 8b83432..97dfdcd 100644 --- a/front/src/components/profile/BirthdayCelebration.js +++ b/front/src/components/profile/BirthdayCelebration.js @@ -20,7 +20,9 @@ function todayKey() { export default function BirthdayCelebration() { const { userToken } = useAuth(); - const [state, setState] = useState({ visible: false, pseudo: null, rewarded: false }); + const [state, setState] = useState({ + visible: false, pseudo: null, rewarded: false, chestKeyAdded: false, trophyUnlocked: false, + }); useEffect(() => { if (!userToken) return; @@ -34,7 +36,13 @@ export default function BirthdayCelebration() { if (!res?.isBirthday) return; await AsyncStorage.setItem(SHOWN_KEY, todayKey()); - setState({ visible: true, pseudo: res.pseudo || null, rewarded: !!res.rewarded }); + setState({ + visible: true, + pseudo: res.pseudo || null, + rewarded: !!res.rewarded, + chestKeyAdded: !!res.chestKeyAdded, + trophyUnlocked: Array.isArray(res.newlyUnlocked) && res.newlyUnlocked.length > 0, + }); } catch (_) { // Silencieux : ne doit jamais bloquer le démarrage de l'app. } @@ -52,6 +60,8 @@ export default function BirthdayCelebration() { visible={state.visible} pseudo={state.pseudo} rewarded={state.rewarded} + chestKeyAdded={state.chestKeyAdded} + trophyUnlocked={state.trophyUnlocked} onClose={handleClose} /> ); diff --git a/front/src/components/profile/BirthdayModal.js b/front/src/components/profile/BirthdayModal.js index 120b304..d326b3b 100644 --- a/front/src/components/profile/BirthdayModal.js +++ b/front/src/components/profile/BirthdayModal.js @@ -1,5 +1,5 @@ -import React from 'react'; -import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import React, { useState, useRef, useEffect } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import BirthdayConfetti from './BirthdayConfetti'; @@ -8,49 +8,99 @@ import BirthdayConfetti from './BirthdayConfetti'; // Modale festive violette déclenchée à l'ouverture de l'app le jour de // l'anniversaire de l'utilisateur (voir BirthdayCelebration.js). // +// Quand un cadeau vient d'être accordé (rewarded), la modale s'ouvre en mode +// "cadeau emballé" : il faut appuyer pour l'ouvrir avant de voir le détail +// des récompenses (déjà créditées en base côté serveur — voir +// reward.controller.js → checkBirthday — l'ouverture n'est qu'une mise en +// scène, pas une seconde étape d'octroi). +// // Props : -// visible bool -// pseudo string | null -// rewarded bool — true la première ouverture du jour (cadeau tout juste accordé) -// onClose () => void +// visible bool +// pseudo string | null +// rewarded bool — true la première ouverture du jour (cadeau tout juste accordé) +// chestKeyAdded bool — +1 CHEST_KEY déjà crédité en base +// trophyUnlocked bool — au moins un trophée déjà débloqué en base +// onClose () => void + +export default function BirthdayModal({ visible, pseudo, rewarded, chestKeyAdded, trophyUnlocked, onClose }) { + const [opened, setOpened] = useState(false); + const giftScale = useRef(new Animated.Value(1)).current; + const rewardsOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + setOpened(false); + giftScale.setValue(1); + rewardsOpacity.setValue(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + const handleOpenGift = () => { + Animated.sequence([ + Animated.spring(giftScale, { toValue: 1.25, friction: 4, useNativeDriver: true }), + Animated.timing(giftScale, { toValue: 1, duration: 120, useNativeDriver: true }), + ]).start(); + setOpened(true); + Animated.timing(rewardsOpacity, { toValue: 1, duration: 320, delay: 100, useNativeDriver: true }).start(); + }; + + const showWrappedGift = rewarded && !opened; -export default function BirthdayModal({ visible, pseudo, rewarded, onClose }) { return ( - + - - - + + + Joyeux Anniversaire{pseudo ? ` ${pseudo}` : ''} ! - - {rewarded - ? "Toute l'équipe Athly te souhaite une excellente année. Ton coffre et ton trophée t'attendent dans ton inventaire !" - : "Toute l'équipe Athly te souhaite une excellente année. Profite bien de ta journée !"} - + {showWrappedGift ? ( + <> + + Toute l'équipe Athly te souhaite une excellente année. Un cadeau t'attend juste en dessous... + + + + Ouvrir mon cadeau + + + ) : ( + <> + + {rewarded + ? "Toute l'équipe Athly te souhaite une excellente année. Tes récompenses sont déjà dans ton inventaire !" + : "Toute l'équipe Athly te souhaite une excellente année. Profite bien de ta journée !"} + - {rewarded && ( - - - - +1 Coffre - - - - Trophée débloqué - - - )} + {rewarded && ( + + {chestKeyAdded && ( + + + +1 Coffre + + )} + {trophyUnlocked && ( + + + Trophée débloqué + + )} + + )} - - Merci Athly ! - + + Merci Athly ! + + + )} @@ -105,6 +155,21 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 20, }, + openBtn: { + flexDirection: 'row', + width: '100%', + height: 50, + borderRadius: 13, + backgroundColor: Colors.rankViolet, + justifyContent: 'center', + alignItems: 'center', + shadowColor: Colors.rankViolet, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + openBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, rewardRow: { flexDirection: 'row', gap: 8, diff --git a/front/src/components/profile/PersonalRecordsList.js b/front/src/components/profile/PersonalRecordsList.js index 8b280b1..adedd58 100644 --- a/front/src/components/profile/PersonalRecordsList.js +++ b/front/src/components/profile/PersonalRecordsList.js @@ -3,18 +3,31 @@ import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors, MUSCLE_GROUP_COLORS } from '../../constants/theme'; -// Liste des records personnels pour les exos majeurs. -// `records` : sortie de stats.service.getPersonalRecords() +// Liste des records personnels — utilisée à l'identique sur son propre profil +// ET sur le profil d'un ami (Section III), pour une présentation cohérente. +// `records` : sortie de stats.service.getPersonalRecords() OU construite +// depuis les records mis en avant (ProfileScreen.js / FriendProfileScreen.js) // // Props : -// - records : Array<{ name, group, icon, prWeight, prEstimate1RM, totalSessions, hasData }> +// - records : Array<{ name, group, icon, prWeight, prEstimate1RM, totalSessions, +// hasData, subtitle? }> +// `subtitle`, si fourni, remplace le texte "N sessions"/"Pas encore fait" +// (utilisé quand le nombre de séances n'est pas connu — profil d'ami). +// - emptyLabel : titre de l'état vide quand `records` est vide (aucun record +// mis en avant) — personnalisable ("Aucun record mis en avant" côté soi, +// "Aucun record pour l'instant" côté ami). // - onPressItem : (record) => void → navigation vers ExerciseStatsScreen // -export default function PersonalRecordsList({ records = [], onPressItem }) { +export default function PersonalRecordsList({ records = [], onPressItem, emptyLabel = 'Aucun record mis en avant' }) { const hasAny = records.some((r) => r.hasData); if (records.length === 0) { - return null; + return ( + + + {emptyLabel} + + ); } if (!hasAny) { @@ -58,14 +71,16 @@ function RecordRow({ record, isLast, onPress }) { {record.name} - {record.totalSessions > 0 ? `${record.totalSessions} session${record.totalSessions > 1 ? 's' : ''}` : 'Pas encore fait'} + {record.subtitle + ? record.subtitle + : record.totalSessions > 0 ? `${record.totalSessions} session${record.totalSessions > 1 ? 's' : ''}` : 'Pas encore fait'} {record.hasData ? ( <> {record.prWeight} kg - 1RM ~{record.prEstimate1RM} + {record.prEstimate1RM != null && 1RM ~{record.prEstimate1RM}} ) : ( diff --git a/front/src/components/profile/RecordsShowcasePicker.js b/front/src/components/profile/RecordsShowcasePicker.js new file mode 100644 index 0000000..7f706fa --- /dev/null +++ b/front/src/components/profile/RecordsShowcasePicker.js @@ -0,0 +1,226 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, FlatList, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import { getMyRecords } from '../../services/social.service'; +import { updateRecordsShowcase } from '../../services/profile.service'; + +const MAX_SHOWCASED = 6; + +function normalize(s) { + return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); +} + +// ─── RecordsShowcasePicker ───────────────────────────────────────────────────── +// Sélection de jusqu'à 6 records d'exercices à mettre en avant sur le profil +// (Section III) — recherche parmi TOUS les exercices déjà pratiqués (peuvent +// être très nombreux), pas seulement les 7 "phares". +// +// Props : +// visible bool +// current string[] — noms actuellement mis en avant (présélectionnés) +// onSaved (names: string[]) => void — appelé après sauvegarde réussie +// onClose () => void + +export default function RecordsShowcasePicker({ visible, current = [], onSaved, onClose }) { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [records, setRecords] = useState([]); + const [selected, setSelected] = useState([]); + const [query, setQuery] = useState(''); + + useEffect(() => { + if (!visible) return; + setSelected(current); + setQuery(''); + setLoading(true); + getMyRecords() + .then((res) => setRecords(Array.isArray(res.records) ? res.records : [])) + .catch(() => setRecords([])) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + const filtered = useMemo(() => { + const q = normalize(query.trim()); + if (!q) return records; + return records.filter((r) => normalize(r.exercice).includes(q)); + }, [records, query]); + + const toggle = (name) => { + setSelected((prev) => { + if (prev.includes(name)) return prev.filter((n) => n !== name); + if (prev.length >= MAX_SHOWCASED) return prev; + return [...prev, name]; + }); + }; + + const handleSave = async () => { + setSaving(true); + try { + const res = await updateRecordsShowcase(selected); + onSaved?.(res.showcasedRecords ?? selected); + onClose?.(); + } catch (_) { + // Best-effort : la modale reste ouverte pour réessayer. + } finally { + setSaving(false); + } + }; + + return ( + + + + + + + + Records mis en avant + {selected.length}/{MAX_SHOWCASED} sélectionnés + + + + + + + {loading ? ( + + ) : records.length === 0 ? ( + + + Aucun record pour l'instant + + Termine des séances avec des poids enregistrés pour pouvoir en mettre en avant ici. + + + ) : ( + <> + + + + + + r.exercice} + keyboardShouldPersistTaps="handled" + style={styles.list} + contentContainerStyle={{ paddingBottom: 8 }} + ListEmptyComponent={Aucun exercice ne correspond.} + renderItem={({ item }) => { + const active = selected.includes(item.exercice); + const disabled = !active && selected.length >= MAX_SHOWCASED; + return ( + toggle(item.exercice)} + disabled={disabled} + activeOpacity={0.75} + > + + + {item.exercice} + + {item.maxPoids} kg × {item.maxReps} + + + + ); + }} + /> + + + {saving + ? + : Enregistrer} + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.7)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: '#13131C', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 20, + paddingTop: 10, + paddingBottom: 20, + // Hauteur FIXE (pas maxHeight) : sans ça, la feuille ne prend que la + // hauteur de son contenu et peut apparaître comme une toute petite boîte + // collée en bas de l'écran quand la liste est courte. Même pattern que + // AddExerciseSheet.js. + height: '78%', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + handle: { + width: 40, height: 4, borderRadius: 2, + backgroundColor: 'rgba(255,255,255,0.15)', + alignSelf: 'center', + marginBottom: 14, + }, + header: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + marginBottom: 14, + }, + title: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' }, + subtitle: { color: Colors.textMuted, fontSize: 11.5, fontWeight: '600', marginTop: 2 }, + searchBox: { + flexDirection: 'row', alignItems: 'center', gap: 8, + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', + borderRadius: 12, paddingHorizontal: 12, height: 44, + marginBottom: 10, + }, + searchInput: { flex: 1, color: Colors.textPrimary, fontSize: 14 }, + list: { flex: 1 }, + row: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingVertical: 12, paddingHorizontal: 10, + borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.06)', + }, + rowActive: { backgroundColor: 'rgba(254,116,57,0.08)', borderRadius: 8 }, + rowContent: { flex: 1, marginRight: 10 }, + rowName: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' }, + rowNameDisabled: { color: Colors.textMuted }, + rowValue: { color: Colors.textMuted, fontSize: 11.5, marginTop: 2, fontWeight: '600' }, + saveBtn: { + height: 50, borderRadius: 13, justifyContent: 'center', alignItems: 'center', + backgroundColor: Colors.primary, marginTop: 14, + shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, shadowRadius: 12, elevation: 6, + }, + saveBtnDisabled: { opacity: 0.6 }, + saveBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + loadingBox: { paddingVertical: 40, alignItems: 'center' }, + emptyBox: { alignItems: 'center', paddingVertical: 30, gap: 8 }, + emptyTitle: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700', marginTop: 6 }, + emptyTxt: { color: Colors.textMuted, fontSize: 12.5, textAlign: 'center', lineHeight: 18, paddingHorizontal: 12 }, +}); diff --git a/front/src/components/social/ActivityFeedModal.js b/front/src/components/social/ActivityFeedModal.js index ad45226..21472b6 100644 --- a/front/src/components/social/ActivityFeedModal.js +++ b/front/src/components/social/ActivityFeedModal.js @@ -22,11 +22,23 @@ const REACTION_META = { jealous: { icon: 'eye', label: 'Jalouser' }, }; +// Titres piochés au hasard à chaque ouverture — un seul nom fixe finit par +// lasser, façon rubrique people plutôt qu'écran technique. +const FEED_TITLES = [ + 'Radio Vestiaire', + 'Le Ragot du Jour', + 'Commérages de la Fonte', + 'Ça jase au vestiaire', + 'Le Bureau des Rumeurs', + "Les Nouvelles Fraîches (et un peu piquantes)", +]; + export default function ActivityFeedModal() { const { userToken } = useAuth(); const [events, setEvents] = useState([]); const [visible, setVisible] = useState(false); const [reactedIds, setReactedIds] = useState({}); + const [title] = useState(() => FEED_TITLES[Math.floor(Math.random() * FEED_TITLES.length)]); useEffect(() => { if (!userToken) return; @@ -63,7 +75,7 @@ export default function ActivityFeedModal() { QUOI DE NEUF ? - Taquineries & High-Fives + {title} diff --git a/front/src/components/social/AddFriendModal.js b/front/src/components/social/AddFriendModal.js new file mode 100644 index 0000000..0abdba0 --- /dev/null +++ b/front/src/components/social/AddFriendModal.js @@ -0,0 +1,221 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +// ─── AddFriendModal ──────────────────────────────────────────────────────────── +// Point d'entrée unique pour ajouter un ami (Section III) : affiche d'abord +// TON PROPRE tag (à donner à un ami), puis deux champs séparés — pseudo et +// # à 4 chiffres — plutôt qu'un seul champ "Pseudo#1234" à taper à la main, +// pour éviter toute confusion de format. +// +// Sur "Rechercher", délègue à `onSearch(fullTag)` (SocialScreen gère déjà la +// recherche + la carte Preview) puis se ferme si un résultat est trouvé — +// géré par le parent via `closeOnFound`. +// +// Props : +// visible bool +// myTag string | null — "MonPseudo#1234", affiché en haut +// searching bool +// error string +// onSearch (fullTag: string) => void +// onClose () => void + +export default function AddFriendModal({ visible, myTag, searching, error, onSearch, onClose }) { + const [pseudo, setPseudo] = useState(''); + const [tag4, setTag4] = useState(''); + + useEffect(() => { + if (visible) { setPseudo(''); setTag4(''); } + }, [visible]); + + const canSearch = pseudo.trim().length > 0 && /^\d{4}$/.test(tag4); + + const handleSearch = () => { + if (!canSearch) return; + onSearch(`${pseudo.trim()}#${tag4}`); + }; + + return ( + + + + + + + + + + + Ajouter un ami + + {!!myTag && ( + + Ton tag (donne-le à un ami) + {myTag} + + )} + + + Entre le pseudo et le # à 4 chiffres exacts de la personne à ajouter. + + + + + + + # + + setTag4(t.replace(/[^0-9]/g, '').slice(0, 4))} + placeholder="0000" + placeholderTextColor={Colors.textMuted} + keyboardType="number-pad" + maxLength={4} + returnKeyType="search" + onSubmitEditing={handleSearch} + /> + + + {!!error && {error}} + + + {searching + ? + : ( + <> + + Rechercher + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 24, + paddingTop: 36, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + closeIcon: { position: 'absolute', top: 14, right: 14, zIndex: 1 }, + iconWrap: { + width: 56, + height: 56, + borderRadius: 16, + backgroundColor: `${Colors.primary}1A`, + borderWidth: 1, + borderColor: `${Colors.primary}45`, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 14, + }, + title: { + color: Colors.textPrimary, + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 16, + textAlign: 'center', + }, + myTagBox: { + width: '100%', + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, + borderColor: Colors.borderSubtle, + borderRadius: 12, + paddingVertical: 10, + alignItems: 'center', + marginBottom: 16, + }, + myTagLabel: { color: Colors.textMuted, fontSize: 10.5, fontWeight: '700', letterSpacing: 0.4 }, + myTagValue: { color: Colors.primary, fontSize: 16, fontWeight: '800', marginTop: 3 }, + body: { + color: Colors.textSecondary, + fontSize: 13, + lineHeight: 19, + textAlign: 'center', + marginBottom: 18, + }, + inputsRow: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + gap: 8, + }, + pseudoInputWrap: { + flex: 2, + borderWidth: 1, + borderColor: Colors.borderSubtle, + borderRadius: 13, + backgroundColor: 'rgba(255,255,255,0.04)', + paddingHorizontal: 14, + height: 50, + justifyContent: 'center', + }, + hash: { color: Colors.textMuted, fontSize: 20, fontWeight: '800' }, + tagInputWrap: { + flex: 1, + borderWidth: 1, + borderColor: Colors.borderSubtle, + borderRadius: 13, + backgroundColor: 'rgba(255,255,255,0.04)', + paddingHorizontal: 14, + height: 50, + justifyContent: 'center', + }, + input: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700' }, + error: { color: Colors.error, fontSize: 12, marginTop: 10, alignSelf: 'flex-start' }, + searchBtn: { + flexDirection: 'row', + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Colors.primary, + marginTop: 20, + shadowColor: Colors.primary, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + searchBtnDisabled: { opacity: 0.4 }, + searchBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/components/social/ExercisePickerModal.js b/front/src/components/social/ExercisePickerModal.js new file mode 100644 index 0000000..f6c7f0c --- /dev/null +++ b/front/src/components/social/ExercisePickerModal.js @@ -0,0 +1,170 @@ +import React, { useState, useMemo } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, FlatList } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import { BUILTIN_CATALOG } from '../../data/exerciseCatalog'; + +// ─── ExercisePickerModal ────────────────────────────────────────────────────── +// Sélecteur d'exercice pour le classement par exercice (Section III) — la +// liste complète du catalogue (~100+ exercices) ne tient pas dans une simple +// rangée de chips : recherche par nom + regroupement par groupe musculaire, +// même esprit que AddExerciseSheet.js côté séance. +// +// Props : +// visible bool +// value string — nom de l'exercice actuellement sélectionné +// onSelect (name: string) => void +// onClose () => void + +const GROUP_LABELS = { + pectoraux: 'Pectoraux', + dos: 'Dos', + epaules: 'Épaules', + bras: 'Bras', + jambes: 'Jambes', + abdos: 'Abdos', +}; + +function normalize(s) { + return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); +} + +export default function ExercisePickerModal({ visible, value, onSelect, onClose }) { + const [query, setQuery] = useState(''); + + const sections = useMemo(() => { + const q = normalize(query.trim()); + const filtered = BUILTIN_CATALOG.filter((ex) => !q || normalize(ex.name).includes(q)); + + const byGroup = new Map(); + for (const ex of filtered) { + const key = ex.targetMuscleGroup || 'autre'; + if (!byGroup.has(key)) byGroup.set(key, []); + byGroup.get(key).push(ex); + } + return Array.from(byGroup.entries()).map(([group, exercises]) => ({ + group, + label: GROUP_LABELS[group] || group, + exercises: exercises.sort((a, b) => a.name.localeCompare(b.name)), + })); + }, [query]); + + const handleSelect = (name) => { + onSelect(name); + setQuery(''); + onClose(); + }; + + return ( + + + + + + + Choisir un exercice + + + + + + + + + + + s.group} + keyboardShouldPersistTaps="handled" + style={styles.list} + contentContainerStyle={{ paddingBottom: 24 }} + ListEmptyComponent={Aucun exercice ne correspond.} + renderItem={({ item: section }) => ( + + {section.label.toUpperCase()} + {section.exercises.map((ex) => { + const active = ex.name === value; + return ( + handleSelect(ex.name)} + activeOpacity={0.75} + > + {ex.name} + {active && } + + ); + })} + + )} + /> + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.7)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: '#13131C', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 20, + paddingTop: 10, + paddingBottom: 16, + // Hauteur FIXE (pas maxHeight) : sinon la feuille ne prend que la + // hauteur de son contenu et peut apparaître comme une petite boîte + // collée en bas de l'écran. Même pattern que AddExerciseSheet.js. + height: '78%', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + handle: { + width: 40, height: 4, borderRadius: 2, + backgroundColor: 'rgba(255,255,255,0.15)', + alignSelf: 'center', + marginBottom: 14, + }, + header: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + marginBottom: 14, + }, + title: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' }, + searchBox: { + flexDirection: 'row', alignItems: 'center', gap: 8, + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', + borderRadius: 12, paddingHorizontal: 12, height: 44, + marginBottom: 12, + }, + searchInput: { flex: 1, color: Colors.textPrimary, fontSize: 14 }, + list: { flex: 1 }, + sectionLabel: { + color: Colors.textMuted, fontSize: 11, fontWeight: '800', letterSpacing: 0.8, + marginTop: 14, marginBottom: 6, + }, + row: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingVertical: 12, paddingHorizontal: 4, + borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.06)', + }, + rowActive: { backgroundColor: 'rgba(254,116,57,0.08)', borderRadius: 8 }, + rowTxt: { color: Colors.textSecondary, fontSize: 14, fontWeight: '600' }, + rowTxtActive: { color: Colors.primary, fontWeight: '800' }, + emptyTxt: { color: Colors.textMuted, fontSize: 13, textAlign: 'center', marginTop: 30 }, +}); diff --git a/front/src/components/social/FriendPreviewModal.js b/front/src/components/social/FriendPreviewModal.js new file mode 100644 index 0000000..38af755 --- /dev/null +++ b/front/src/components/social/FriendPreviewModal.js @@ -0,0 +1,169 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import HeroLevelCard from '../profile/HeroLevelCard'; + +// ─── FriendPreviewModal ──────────────────────────────────────────────────────── +// Carte "Preview" (Section III) : affichée après une recherche par tag exact +// "Pseudo#1234" AVANT tout envoi d'invitation — confirme qu'il s'agit bien de +// la bonne personne (avatar, cadre équipé, niveau, rang) plutôt que d'envoyer +// une demande à l'aveugle. +// +// Props : +// visible bool +// result { user: {pseudo, discriminator, xp, equippedFrame}, relationStatus, requestId } | null +// onSend () => Promise — "Envoyer la demande d'ami" +// onClose () => void + +export default function FriendPreviewModal({ visible, result, onSend, onClose }) { + const [sending, setSending] = useState(false); + if (!result) return null; + + const { user, relationStatus } = result; + const frame = user.equippedFrame || { shapeId: 'circle', colorId: 'none' }; + + const handleSend = async () => { + setSending(true); + try { + await onSend(); + } finally { + setSending(false); + } + }; + + return ( + + + + + + + + C'EST BIEN LUI / ELLE ? + + + + + + {user.pseudo}#{user.discriminator} + + {relationStatus === 'none' && ( + + {sending + ? + : ( + <> + + Envoyer la demande d'ami + + )} + + )} + {relationStatus === 'pending_sent' && ( + + + Invitation déjà envoyée + + )} + {relationStatus === 'pending_received' && ( + + + Vous a envoyé une invitation — vois l'onglet Demandes + + )} + {relationStatus === 'accepted' && ( + + + Déjà ami + + )} + + + Fermer + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 24, + paddingTop: 36, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + closeIcon: { position: 'absolute', top: 14, right: 14, zIndex: 1 }, + eyebrow: { + color: Colors.textMuted, + fontSize: 11, + fontWeight: '800', + letterSpacing: 1.5, + marginBottom: 16, + textAlign: 'center', + }, + heroWrap: { width: '100%', marginBottom: 12 }, + tag: { + color: Colors.textSecondary, + fontSize: 13, + fontWeight: '700', + marginBottom: 20, + }, + sendBtn: { + flexDirection: 'row', + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Colors.primary, + marginBottom: 10, + shadowColor: Colors.primary, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + sendBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + statusChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 7, + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, + borderColor: Colors.borderSubtle, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 14, + width: '100%', + justifyContent: 'center', + marginBottom: 10, + }, + statusChipTxt: { color: Colors.textSecondary, fontSize: 12.5, fontWeight: '600', textAlign: 'center', flexShrink: 1 }, + cancelBtn: { width: '100%', height: 44, justifyContent: 'center', alignItems: 'center' }, + cancelTxt: { color: Colors.textMuted, fontSize: 14, fontWeight: '500' }, +}); diff --git a/front/src/components/stats/WeightProgressChart.js b/front/src/components/stats/WeightProgressChart.js new file mode 100644 index 0000000..c34913e --- /dev/null +++ b/front/src/components/stats/WeightProgressChart.js @@ -0,0 +1,147 @@ +import React, { useMemo } from 'react'; +import { View, Text, Dimensions, Platform, StyleSheet } from 'react-native'; +import { LineChart } from 'react-native-chart-kit'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +// ─── WeightProgressChart ────────────────────────────────────────────────────── +// Double courbe (Section VI) : poids réel (ligne pleine) + objectif (ligne +// horizontale en pointillés, si `goal` est renseigné), avec un indicateur du +// delta restant. +// +// `history` : Array<{ weight: number, date: string|Date }> — triée +// chronologiquement (voir weight.service.js → getWeightHistory). +// `goal` : number | null — `poidsCible` du profil utilisateur. + +function formatDate(d) { + const dt = new Date(d); + return `${String(dt.getDate()).padStart(2, '0')}/${String(dt.getMonth() + 1).padStart(2, '0')}`; +} + +export default function WeightProgressChart({ history = [], goal = null, height = 200 }) { + const rawW = Dimensions.get('window').width; + const screenW = Platform.OS === 'web' ? Math.min(430, rawW) : rawW; + const chartW = Math.max(220, screenW - 72); + + const { labels, weights, hasData, currentWeight } = useMemo(() => { + const arr = (Array.isArray(history) ? history : []).filter((p) => p && Number(p.weight) > 0); + if (arr.length === 0) return { labels: [], weights: [], hasData: false, currentWeight: null }; + + const lbls = arr.map((p, i) => { + if (i === 0 || i === arr.length - 1 || i === Math.floor(arr.length / 2)) return formatDate(p.date); + return ''; + }); + const values = arr.map((p) => Number(p.weight)); + return { labels: lbls, weights: values, hasData: true, currentWeight: values[values.length - 1] }; + }, [history]); + + if (!hasData) { + return ( + + + Aucune pesée enregistrée + Ajoute ton poids pour voir ta courbe apparaître ici. + + ); + } + + // chart-kit gère mal un seul point : on duplique pour éviter un crash visuel. + const weightData = weights.length === 1 ? [weights[0], weights[0]] : weights; + const lbls = weights.length === 1 ? [labels[0] || '', labels[0] || ''] : labels; + + const datasets = [{ data: weightData, color: () => Colors.primary, strokeWidth: 3 }]; + if (goal) { + datasets.push({ + data: weightData.map(() => goal), + color: () => Colors.gold, + strokeWidth: 2, + strokeDashArray: [6, 4], + withDots: false, + }); + } + + const delta = goal ? currentWeight - goal : null; + const atGoal = delta !== null && Math.abs(delta) < 0.1; + + return ( + + + + {goal != null && ( + + + + {atGoal + ? 'Objectif de poids atteint !' + : `Plus que ${Math.abs(delta).toFixed(1)} kg avant ton objectif !`} + + + )} + + ); +} + +const CHART_CONFIG = { + backgroundGradientFrom: Colors.cardDeep, + backgroundGradientTo: Colors.cardDeep, + decimalPlaces: 1, + color: (opacity = 1) => `rgba(254, 116, 57, ${opacity})`, + labelColor: () => Colors.textMuted, + propsForDots: { + r: '4', + strokeWidth: '2', + stroke: '#0A0A0A', + }, + propsForBackgroundLines: { + stroke: '#23232b', + }, +}; + +const styles = StyleSheet.create({ + wrap: { overflow: 'hidden' }, + chart: { marginLeft: -10, borderRadius: 12 }, + deltaRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 7, + marginTop: 12, + backgroundColor: 'rgba(255,215,0,0.08)', + borderWidth: 1, + borderColor: 'rgba(255,215,0,0.25)', + borderRadius: 12, + paddingVertical: 10, + }, + deltaTxt: { color: Colors.textPrimary, fontSize: 13, fontWeight: '700' }, + empty: { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + }, + emptyText: { + color: Colors.textPrimary, + fontSize: 14, + fontWeight: '600', + textAlign: 'center', + }, + emptyHint: { + color: Colors.textMuted, + fontSize: 12, + textAlign: 'center', + marginTop: 6, + }, +}); diff --git a/front/src/components/stats/WeightReminderCheck.js b/front/src/components/stats/WeightReminderCheck.js new file mode 100644 index 0000000..0b1cae4 --- /dev/null +++ b/front/src/components/stats/WeightReminderCheck.js @@ -0,0 +1,63 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useAuth } from '../../context/AuthContext'; +import { getWeightHistory } from '../../services/weight.service'; +import WeightReminderModal from './WeightReminderModal'; +import WeightEntryModal from '../common/WeightEntryModal'; + +const DISMISSED_AT_KEY = 'athly:weight:reminder:dismissed_at:v1'; +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +// ─── WeightReminderCheck ────────────────────────────────────────────────────── +// À monter une fois dans l'arbre authentifié (AppNavigator), au même niveau +// que BirthdayCelebration / ActivityFeedModal. Vérifie au démarrage si la +// dernière pesée enregistrée date de plus de 7 jours et, si oui, propose la +// modale de rappel — sauf si l'utilisateur a déjà cliqué "Plus tard" au +// cours des 7 derniers jours (pas de spam avant la semaine suivante). + +export default function WeightReminderCheck() { + const { userToken } = useAuth(); + const [reminderVisible, setReminderVisible] = useState(false); + const [entryVisible, setEntryVisible] = useState(false); + + useEffect(() => { + if (!userToken) return; + (async () => { + try { + const dismissedAtRaw = await AsyncStorage.getItem(DISMISSED_AT_KEY); + if (dismissedAtRaw) { + const elapsed = Date.now() - new Date(dismissedAtRaw).getTime(); + if (elapsed < SEVEN_DAYS_MS) return; // déjà repoussé cette semaine + } + + const res = await getWeightHistory(); + const history = Array.isArray(res.history) ? res.history : []; + const lastEntry = history[history.length - 1]; + const lastWeighInAge = lastEntry ? Date.now() - new Date(lastEntry.date).getTime() : Infinity; + + if (lastWeighInAge >= SEVEN_DAYS_MS) { + setReminderVisible(true); + } + } catch (_) { + // Best-effort — ne doit jamais bloquer le démarrage de l'app. + } + })(); + }, [userToken]); + + const handleLater = useCallback(async () => { + setReminderVisible(false); + try { await AsyncStorage.setItem(DISMISSED_AT_KEY, new Date().toISOString()); } catch (_) {} + }, []); + + const handleEnter = useCallback(() => { + setReminderVisible(false); + setEntryVisible(true); + }, []); + + return ( + <> + + setEntryVisible(false)} /> + + ); +} diff --git a/front/src/components/stats/WeightReminderModal.js b/front/src/components/stats/WeightReminderModal.js new file mode 100644 index 0000000..407f2a0 --- /dev/null +++ b/front/src/components/stats/WeightReminderModal.js @@ -0,0 +1,113 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +// ─── WeightReminderModal ────────────────────────────────────────────────────── +// Rappel hebdomadaire de pesée (Section VI) : déclenché au lancement de l'app +// si aucune pesée n'a été enregistrée depuis plus de 7 jours (voir +// WeightReminderCheck.js, monté une fois dans navigation/index.js). +// +// Modale custom (jamais Alert.alert), cohérente avec ConfirmModal.js. +// +// Props : +// visible bool +// onEnter () => void — "Entrer mon poids" → ouvre WeightEntryModal +// onLater () => void — "Plus tard" → ferme sans pénalité, ne re-sollicite +// pas avant la semaine suivante (géré par l'appelant) + +export default function WeightReminderModal({ visible, onEnter, onLater }) { + return ( + + + + + + + + Petit rappel poids + + Tiens, tu n'as pas encore entré ton poids cette semaine ! Rentre-le pour garder un suivi au top. + + + + + Entrer mon poids + + + + Plus tard + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 28, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + iconWrap: { + width: 60, + height: 60, + borderRadius: 18, + backgroundColor: `${Colors.primary}1A`, + borderWidth: 1, + borderColor: `${Colors.primary}45`, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 18, + }, + title: { + color: Colors.textPrimary, + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 10, + textAlign: 'center', + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 24, + }, + enterBtn: { + flexDirection: 'row', + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Colors.primary, + marginBottom: 10, + shadowColor: Colors.primary, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + enterBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + laterBtn: { width: '100%', height: 44, justifyContent: 'center', alignItems: 'center' }, + laterTxt: { color: Colors.textMuted, fontSize: 14, fontWeight: '500' }, +}); diff --git a/front/src/data/majorExercises.js b/front/src/data/majorExercises.js index 84eb894..8dba030 100644 --- a/front/src/data/majorExercises.js +++ b/front/src/data/majorExercises.js @@ -5,6 +5,9 @@ // Ordre = ordre d'affichage (priorité visuelle décroissante). // Garde la liste courte (6-8 exos max) pour préserver la lisibilité du profil. +import { BUILTIN_CATALOG } from './exerciseCatalog'; +import { ICON_FOR_MUSCLE_GROUP, DEFAULT_ICON, normalizeId } from '../constants/exerciseFilters'; + export const MAJOR_EXERCISES = [ { name: 'Développé couché', group: 'pectoraux', icon: 'barbell-outline' }, { name: 'Squat', group: 'jambes', icon: 'barbell-outline' }, @@ -25,3 +28,23 @@ export function findMajorExerciseByName(name) { return k === target; }) || null; } + +// Résout { group, icon } pour N'IMPORTE QUEL nom d'exercice (pas seulement les +// 7 "phares") — utilisé par les records mis en avant, qui peuvent venir de +// tout le catalogue. Ordre de résolution : MAJOR_EXERCISES (icône curatée) → +// BUILTIN_CATALOG (groupe musculaire réel, icône déduite) → repli neutre. +export function resolveExerciseMeta(name) { + const major = findMajorExerciseByName(name); + if (major) return { group: major.group, icon: major.icon }; + + const targetId = normalizeId(name); + const catalogMatch = BUILTIN_CATALOG.find((ex) => normalizeId(ex.name) === targetId); + if (catalogMatch) { + return { + group: catalogMatch.targetMuscleGroup, + icon: ICON_FOR_MUSCLE_GROUP[catalogMatch.targetMuscleGroup] || DEFAULT_ICON, + }; + } + + return { group: null, icon: DEFAULT_ICON }; +} diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js index 184a698..5dcd224 100644 --- a/front/src/navigation/index.js +++ b/front/src/navigation/index.js @@ -23,6 +23,7 @@ import { registerPushToken } from '../services/profile.service'; import BirthdayCelebration from '../components/profile/BirthdayCelebration'; import LevelUpCelebration from '../components/profile/LevelUpCelebration'; import ActivityFeedModal from '../components/social/ActivityFeedModal'; +import WeightReminderCheck from '../components/stats/WeightReminderCheck'; const NOTIF_ENABLED_KEY = 'athly:notif:enabled:v1'; @@ -79,6 +80,7 @@ export default function AppNavigator() { + )} diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js index 6c5ec35..2f175cc 100644 --- a/front/src/screens/Profile/ProfileScreen.js +++ b/front/src/screens/Profile/ProfileScreen.js @@ -19,13 +19,13 @@ import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import { useUser } from '../../context/UserContext'; import { Colors } from '../../constants/theme'; import { - getPersonalRecords, aggregateActivityHeatmap, xpToLevel, computeStreak, getRank, } from '../../services/stats.service'; -import { MAJOR_EXERCISES } from '../../data/majorExercises'; +import { resolveExerciseMeta } from '../../data/majorExercises'; +import { getMyRecords } from '../../services/social.service'; import { syncLocalAchievements } from '../../services/reward.service'; import { useAvatarFrame } from '../../hooks/useAvatarFrame'; import { useDevSettings } from '../../hooks/useDevSettings'; @@ -38,6 +38,7 @@ import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; import HeroLevelCard from '../../components/profile/HeroLevelCard'; import TrophyGrid from '../../components/profile/TrophyGrid'; import PersonalRecordsList from '../../components/profile/PersonalRecordsList'; +import RecordsShowcasePicker from '../../components/profile/RecordsShowcasePicker'; import ActivityHeatmap from '../../components/profile/ActivityHeatmap'; import EmberParticles from '../../components/profile/EmberParticles'; import StreakBadge from '../../components/profile/StreakBadge'; @@ -120,8 +121,43 @@ export default function ProfileScreen({ navigation }) { }, [refetchUser, reloadFeatured, reloadDevSettings]), ); + // ─── Records mis en avant (Section III) ────────────────────────────────── + // Source de vérité backend (ExerciseRecord), pas les logs locaux — pour + // rester identique à ce que voient les amis sur leur profil (getFriendProfile). + const [myRecords, setMyRecords] = useState([]); + const [recordsPickerVisible, setRecordsPickerVisible] = useState(false); + + const loadMyRecords = useCallback(async () => { + try { + const res = await getMyRecords(); + setMyRecords(Array.isArray(res.records) ? res.records : []); + } catch (_) { + // Best-effort — ne doit jamais bloquer l'affichage du profil. + } + }, []); + + useFocusEffect(useCallback(() => { loadMyRecords(); }, [loadMyRecords])); + + const showcasedRecordNames = user?.showcasedRecords ?? []; + + const records = useMemo(() => { + const byName = new Map(myRecords.map((r) => [r.exercice, r])); + return showcasedRecordNames.map((name) => { + const r = byName.get(name); + const meta = resolveExerciseMeta(name); + return { + name, + group: meta.group, + icon: meta.icon, + prWeight: r ? r.maxPoids : null, + prEstimate1RM: r ? Math.round(r.maxPoids * (1 + r.maxReps / 30) * 10) / 10 : null, + hasData: !!r, + subtitle: r ? `${r.maxReps} reps max` : undefined, + }; + }); + }, [myRecords, showcasedRecordNames]); + // ─── Computed values ────────────────────────────────────────────────────── - const records = useMemo(() => getPersonalRecords(logs, MAJOR_EXERCISES), [logs]); const heatmap = useMemo(() => aggregateActivityHeatmap(logs), [logs]); const totalSessions = logs.length; const totalActiveDays = useMemo(() => { @@ -296,9 +332,9 @@ export default function ProfileScreen({ navigation }) { {/* ── Records personnels ── */} -
+
setRecordsPickerVisible(true)} seeAllLabel="Choisir"> - +
@@ -344,20 +380,27 @@ export default function ProfileScreen({ navigation }) { {activeChapterId === 'profile' && ( )} + + { refetchUser(); loadMyRecords(); }} + onClose={() => setRecordsPickerVisible(false)} + /> ); } // ─── Sub-components ─────────────────────────────────────────────────────────── -function Section({ title, children, onSeeAll }) { +function Section({ title, children, onSeeAll, seeAllLabel = 'Voir tout' }) { return ( {title} {onSeeAll && ( - Voir tout + {seeAllLabel} )} diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 061f5cd..d6b7e9d 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -40,6 +40,8 @@ import { import { syncBackendLevel, giveChests, generateMockSocial, giveAllItems, simulateChestsOpened, simulateReferral, simulateBirthday, + simulateGroup, simulateActivityEvent, simulateStreakBreak, simulateShakeSelf, + simulateSearchableFriend, } from '../../services/debug.service'; const UNIT_WEIGHT_KEY = 'athly:unit:weight:v1'; @@ -157,6 +159,7 @@ export default function SettingsScreen({ navigation }) { const [simLoading, setSimLoading] = useState(false); const [simFeedback, setSimFeedback] = useState(''); const [trophyExpanded, setTrophyExpanded] = useState(false); + const [testFriendTag, setTestFriendTag] = useState(null); const [deleteModal1, setDeleteModal1] = useState(false); const [deleteModal2, setDeleteModal2] = useState(false); @@ -370,6 +373,31 @@ export default function SettingsScreen({ navigation }) { } }, [showFeedback]); + // Crée un compte de test PAS déjà ami — seul moyen de tester en solo le + // parcours complet "Ajouter un ami" (recherche par tag, preview, envoi). + // Bloqué en production (404). + const handleSimulateSearchableFriend = useCallback(async () => { + try { + setSimLoading(true); + const res = await simulateSearchableFriend(); + // Le tag doit rester lisible le temps de changer d'écran (Réglages → + // Social) pour le saisir dans "Ajouter un ami" — le feedback normal + // s'efface après 2,5s, largement trop court pour ça. Affiché à part, + // de façon persistante, tant que ce compte de test reste valide. + if (res.tag) { + setTestFriendTag(res.tag); + showFeedback('Compte de test créé — tag affiché ci-dessous ↓'); + } else { + showFeedback(res.message || 'Compte de test créé'); + } + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback]); + // Injecte 1 exemplaire de CHAQUE objet existant (consommables + cosmétiques // Uniques réclamables) pour tout tester en un clic. Bloqué en production (404). const handleGiveAllItems = useCallback(async () => { @@ -446,6 +474,73 @@ export default function SettingsScreen({ navigation }) { } }, [showFeedback, refetchUser, feedbackWithUnlocks]); + // ── Vague 1 : groupe, météo, activité, Hall of Shame, secouer ──────────── + // Ces 4 outils sont le SEUL moyen de tester ces fonctionnalités en solo : + // elles dépendent toutes d'un groupe à plusieurs membres et/ou d'un second + // appareil, ce qu'un testeur seul ne peut pas reproduire manuellement. + + // Crée un groupe de test avec 3 coéquipiers factices (Prêt/Actif/Validé) — + // teste la Météo des séances, le multiplicateur de groupe et sert de base + // aux 3 autres outils ci-dessous. Bloqué en production (404). + const handleSimulateGroup = useCallback(async () => { + try { + setSimLoading(true); + const res = await simulateGroup(); + showFeedback(res.message || 'Groupe de test créé'); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback]); + + // Publie un événement factice au nom d'un coéquipier — teste l'ActivityFeedModal + // et les réactions. Nécessite d'avoir d'abord lancé "Simuler un groupe". + const handleSimulateActivityEvent = useCallback(async () => { + try { + setSimLoading(true); + const res = await simulateActivityEvent(); + showFeedback(res.message || 'Événement simulé'); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback]); + + // Recule lastValidatedDate du groupe pour déclencher le Hall of Shame au + // prochain chargement de l'onglet Groupe. + const handleSimulateStreakBreak = useCallback(async () => { + try { + setSimLoading(true); + const res = await simulateStreakBreak(); + showFeedback(res.message || 'Rupture de streak simulée'); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback]); + + // Envoie une vraie notification push au propre appareil du testeur — seul + // moyen de vérifier de bout en bout que l'infra push fonctionne sans + // second compte/appareil pour recevoir la notification. + const handleSimulateShakeSelf = useCallback(async () => { + try { + setSimLoading(true); + const res = await simulateShakeSelf(); + showFeedback(res.message || (res.pushed ? 'Notification envoyée' : 'Échec d\'envoi')); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback]); + const handleLockDevSection = useCallback(async () => { setDevVisible(false); setTapCount(0); @@ -688,222 +783,289 @@ export default function SettingsScreen({ navigation }) { - {/* ── NIVEAU & XP ── */} - - - - - - - - = 200} flex /> - - - - - - - XP requis Niv.{level + 1} : {xpForLevel(level + 1).toLocaleString('fr-FR')} - - {/* ── SYNC BACKEND ── */} - - - Le niveau ci-dessus est local uniquement. Les coffres et fonctionnalités - serveur (niveau 11+) lisent le niveau backend, synchronise pour les tester. - - - - - - {/* ── SANDBOX INVENTAIRE & SOCIAL ── */} - - - Coffres et faux amis générés directement en base, pour tester - l'Inventaire et l'écran Social sans dizaines de vraies actions. - - - - - - - - « + Coffre(s) » ajoute des CHEST_KEY à ouvrir manuellement. « Simuler ouverts » - incrémente directement le compteur de coffres ouverts (trophées CHEST_1…CHEST_200, - thème Rouge Sang à 100). - - - - - - Crée FauxAmi_1 et FauxAmi_2 (amis acceptés, pour Classement et Groupe) - et FauxAmi_3 (demande en attente, pour Accepter/Refuser). Rejouable sans doublons. - - - - - - Ajoute 1 exemplaire de chaque objet (consommables + cosmétiques Uniques - réclamables) dans l'inventaire, pour tout tester d'un coup. - - - - - - - Débloquent respectivement le trophée « Recruteur Athly » (parrainage) et les - trophées anniversaire, sans attendre un vrai filleul ou le vrai jour J. - - - {/* ── SIMULATION ── */} - - - - - - Injecte des logs réalistes sur les N derniers jours. - - {/* ── STREAK ── */} - - - - - + {/* ── PROGRESSION (Niveau, XP, sync backend) ── */} + + + + + + + + = 200} flex /> + + + + + + + XP requis Niv.{level + 1} : {xpForLevel(level + 1).toLocaleString('fr-FR')} - {/* ── TROPHÉES ── */} - + + + Le niveau ci-dessus est local uniquement. Les coffres et fonctionnalités + serveur (niveau 11+) lisent le niveau backend, synchronise pour les tester. + + + + + - {/* Statut Trophée Ultime */} - - - - {ULTIMATE_TROPHY.label} + {/* ── INVENTAIRE & COFFRES ── */} + + + Coffres injectés directement en base, pour tester l'Inventaire + sans dizaines de vraies actions. - - {ultimateUnlocked ? '✓ Débloqué !' : `${fullTrophyList.filter(t => t.unlocked).length}/${fullTrophyList.length}`} + + + + + + + « + Coffre(s) » ajoute des CHEST_KEY à ouvrir manuellement. « Simuler ouverts » + incrémente directement le compteur de coffres ouverts (trophées CHEST_1…CHEST_200, + thème Rouge Sang à 100). - + + + + + Ajoute 1 exemplaire de chaque objet (consommables + cosmétiques Uniques + réclamables) dans l'inventaire, pour tout tester d'un coup. + + - {/* Actions de masse */} - - { - TROPHY_CATALOG.forEach(t => setTrophyOverride(t.id, true)); - showFeedback('Tous les trophées débloqués ✓'); - }} - disabled={simLoading} - flex - /> - { clearTrophyOverrides(); showFeedback('Overrides réinitialisés ✓'); }} - disabled={simLoading} - flex - variant="dim" - /> - + {/* ── RÉSEAU SOCIAL ── */} + + + Faux amis générés directement en base, pour tester l'écran Social + sans dépendre de vrais comptes tiers. + + + + + + Crée FauxAmi_1 et FauxAmi_2 (amis acceptés, pour Classement et Groupe) + et FauxAmi_3 (demande en attente, pour Accepter/Refuser). Rejouable sans doublons. + + + + + + + Débloquent respectivement le trophée « Recruteur Athly » (parrainage) et les + trophées anniversaire, sans attendre un vrai filleul ou le vrai jour J. + - {/* Accordéon — liste individuelle */} - setTrophyExpanded(v => !v)} - activeOpacity={0.75} - > - - Gestion individuelle des trophées + + + + + + Crée un compte "TestAmi" PAS déjà ami avec un # généré aléatoirement. + Le tag exact reste affiché juste en dessous (pas le message temporaire, + trop court pour changer d'écran) — saisis-le tel quel dans "Ajouter un + ami" côté Social. Ne tape jamais "0000", ce n'est qu'un exemple de format. - - + {testFriendTag && ( + + TAG DU COMPTE DE TEST + {testFriendTag} + + )} + + + {/* ── GROUPE DE STREAK (V2) ── */} + + + Ces outils sont le seul moyen de tester la Météo des séances, le flux + d'activité, le Hall of Shame et le bouton Secouer sans un second + compte/appareil. + + + + + + Crée un groupe avec 3 coéquipiers factices, un par statut de Météo des + séances testable (🔥 Prêt / ⚡ Actif / ✅ Validé — le 4e, 💤 En sommeil, + s'obtient en ne touchant à aucun des trois). Rejouable sans doublons. + + + + + + Publie un PR battu ou un coffre Légendaire au nom d'un coéquipier — + déclenche l'ActivityFeedModal au prochain lancement. Nécessite d'avoir + d'abord simulé un groupe. + + + + + + Recule la dernière validation du groupe pour déclencher le Hall of Shame + dès le prochain chargement de l'onglet Groupe. + + + + + + Envoie une vraie notification push à ton propre appareil, avec le texte + troll du bouton Secouer — vérifie l'infra push de bout en bout. + + - {trophyExpanded && allTrophyCategories.map((cat) => { - const catTrophies = fullTrophyList.filter((t) => t.category === cat.id); - if (catTrophies.length === 0) return null; - return ( - - {cat.label} - {catTrophies.map((t) => ( - - - - - - - {t.label} - - {t.condition} - - {t.isBackend ? ( - // Trophée de compte (serveur) : pas d'override dev possible, - // affiche uniquement le statut réel. - - + {/* ── SIMULATION DE SÉANCES ── */} + + + + + + Injecte des logs réalistes sur les N derniers jours. + + + + + + + + + {/* ── TROPHÉES ── */} + + {/* Statut Trophée Ultime */} + + + + {ULTIMATE_TROPHY.label} + + + {ultimateUnlocked ? '✓ Débloqué !' : `${fullTrophyList.filter(t => t.unlocked).length}/${fullTrophyList.length}`} + + + + {/* Actions de masse */} + + { + TROPHY_CATALOG.forEach(t => setTrophyOverride(t.id, true)); + showFeedback('Tous les trophées débloqués ✓'); + }} + disabled={simLoading} + flex + /> + { clearTrophyOverrides(); showFeedback('Overrides réinitialisés ✓'); }} + disabled={simLoading} + flex + variant="dim" + /> + + + {/* Accordéon — liste individuelle */} + setTrophyExpanded(v => !v)} + activeOpacity={0.75} + > + + Gestion individuelle des trophées + + + + + {trophyExpanded && allTrophyCategories.map((cat) => { + const catTrophies = fullTrophyList.filter((t) => t.category === cat.id); + if (catTrophies.length === 0) return null; + return ( + + {cat.label} + {catTrophies.map((t) => ( + + + - ) : ( - - {!t.naturalUnlocked && trophyOverrides[t.id] === true && ( - DEV - )} - setTrophyOverride(t.id, val === t.naturalUnlocked ? null : val)} - trackColor={{ false: 'rgba(255,255,255,0.10)', true: t.color + 'AA' }} - thumbColor={t.unlocked ? t.color : '#888'} - style={styles.trophySwitch} - /> + + + {t.label} + + {t.condition} - )} - - ))} - - ); - })} + {t.isBackend ? ( + // Trophée de compte (serveur) : pas d'override dev possible, + // affiche uniquement le statut réel. + + + + ) : ( + + {!t.naturalUnlocked && trophyOverrides[t.id] === true && ( + DEV + )} + setTrophyOverride(t.id, val === t.naturalUnlocked ? null : val)} + trackColor={{ false: 'rgba(255,255,255,0.10)', true: t.color + 'AA' }} + thumbColor={t.unlocked ? t.color : '#888'} + style={styles.trophySwitch} + /> + + )} + + ))} + + ); + })} + {/* ── NOTIFICATIONS ── */} - - Déclenche une notification de test dans 3 secondes. Passe l'app en arrière-plan. - - runNotifTest('orange')} disabled={simLoading} flex variant="orange" /> - runNotifTest('violet')} disabled={simLoading} flex variant="violet" /> - + + Déclenche une notification de test dans 3 secondes. Passe l'app en arrière-plan. + + runNotifTest('orange')} disabled={simLoading} flex variant="orange" /> + runNotifTest('violet')} disabled={simLoading} flex variant="violet" /> + + - {/* ── RESET ── */} - - - Décale les séances d'aujourd'hui à hier, relance le gain d'XP. - - Supprime uniquement les logs [DEBUG], les vraies séances sont conservées. + {/* ── RESET / DANGER ZONE ── */} + + + Décale les séances d'aujourd'hui à hier, relance le gain d'XP. + + Supprime uniquement les logs [DEBUG], les vraies séances sont conservées. + {simLoading && ( @@ -1118,11 +1280,27 @@ function SegBtn({ label, active, onPress }) { ); } -function DevSectionTitle({ title }) { +// Section repliable de la console God Mode — chaque nouvel outil se range +// dans une section existante ou en ouvre une nouvelle, sans jamais allonger +// un mur de boutons toujours visible. `defaultOpen` réservé aux sections les +// plus consultées (Progression) ; toutes les autres démarrent repliées. +function DevSection({ title, icon, badge, defaultOpen = false, children }) { + const [open, setOpen] = useState(defaultOpen); return ( - - {title} - + + setOpen((v) => !v)} activeOpacity={0.75}> + + + {title.toUpperCase()} + {badge && ( + + {badge} + + )} + + + + {open && {children}} ); } @@ -1226,9 +1404,30 @@ const styles = StyleSheet.create({ devStatValue: { color: Colors.textPrimary, fontSize: 16, fontWeight: '800' }, devStatLabel: { color: Colors.textMuted, fontSize: 9, fontWeight: '600', marginTop: 2, letterSpacing: 0.5 }, - devSectionRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 4 }, - devSectionTitle:{ color: 'rgba(255,215,0,0.6)', fontSize: 9, fontWeight: '800', letterSpacing: 1.4 }, - devSectionLine: { flex: 1, height: StyleSheet.hairlineWidth, backgroundColor: GOLD_BDR }, + devSectionWrap: { + marginTop: 10, borderRadius: 12, borderWidth: 1, borderColor: GOLD_BDR, + backgroundColor: 'rgba(0,0,0,0.2)', overflow: 'hidden', + }, + devSectionHeader: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingHorizontal: 12, paddingVertical: 11, + }, + devSectionHeaderLeft: { flexDirection: 'row', alignItems: 'center', gap: 8, flexShrink: 1 }, + devSectionTitle:{ color: 'rgba(255,215,0,0.75)', fontSize: 11, fontWeight: '800', letterSpacing: 1.2 }, + devSectionBadge: { + backgroundColor: 'rgba(254,116,57,0.18)', borderWidth: 1, borderColor: 'rgba(254,116,57,0.4)', + borderRadius: 6, paddingHorizontal: 6, paddingVertical: 1, + }, + devSectionBadgeTxt: { color: Colors.primary, fontSize: 9, fontWeight: '800' }, + devSectionBody: { paddingHorizontal: 12, paddingBottom: 14, gap: 10 }, + devSubDivider: { height: StyleSheet.hairlineWidth, backgroundColor: GOLD_BDR, marginVertical: 2 }, + testTagBox: { + backgroundColor: 'rgba(254,116,57,0.10)', + borderWidth: 1, borderColor: 'rgba(254,116,57,0.35)', + borderRadius: 10, paddingVertical: 10, alignItems: 'center', + }, + testTagLabel: { color: 'rgba(255,215,0,0.6)', fontSize: 9.5, fontWeight: '800', letterSpacing: 1 }, + testTagValue: { color: Colors.primary, fontSize: 17, fontWeight: '800', marginTop: 3 }, devInputRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, devInput: { flex: 1, height: 40, borderRadius: 10, borderWidth: 1, borderColor: GOLD_BDR, backgroundColor: 'rgba(0,0,0,0.35)', color: Colors.gold, fontSize: 15, fontWeight: '700', paddingHorizontal: 12 }, diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index 4540314..44ee114 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -16,6 +16,8 @@ import StreakBadge from '../../components/profile/StreakBadge'; import EmberParticles from '../../components/profile/EmberParticles'; import AchievementShowcase from '../../components/profile/AchievementShowcase'; import FriendShowcaseGrid from '../../components/profile/FriendShowcaseGrid'; +import PersonalRecordsList from '../../components/profile/PersonalRecordsList'; +import { resolveExerciseMeta } from '../../data/majorExercises'; // ─── FriendProfileScreen ────────────────────────────────────────────────────── // Profil public d'un ami (Brique III) : miroir en lecture seule de notre propre @@ -71,12 +73,15 @@ export default function FriendProfileScreen({ route, navigation }) { useEffect(() => { load(); }, [load]); + const alreadyShakenToday = (sharedGroup?.shakenTodayByMe ?? []).includes(friendId); + const handleShake = async () => { - if (shaking || !sharedGroup) return; + if (shaking || !sharedGroup || alreadyShakenToday) return; setShaking(true); try { const res = await shakeMember(sharedGroup._id, friendId); showToast(res.message || `${pseudo} a été secoué !`, 'success'); + await load(); } catch (error) { if (!error.isSessionExpired) showToast(error.data?.message || 'Action impossible.', 'error'); } finally { @@ -84,6 +89,25 @@ export default function FriendProfileScreen({ route, navigation }) { } }; + // Records mis en avant par l'ami — même forme que PersonalRecordsList + // utilisée sur son propre profil (ProfileScreen.js), pour un rendu + // identique plutôt qu'une liste texte séparée. + const records = useMemo(() => { + if (!profile) return []; + return (profile.records || []).map((r) => { + const meta = resolveExerciseMeta(r.exercice); + return { + name: r.exercice, + group: meta.group, + icon: meta.icon, + prWeight: r.maxPoids, + prEstimate1RM: Math.round(r.maxPoids * (1 + r.maxReps / 30) * 10) / 10, + hasData: true, + subtitle: `${r.maxReps} reps max`, + }; + }); + }, [profile]); + const level = useMemo(() => xpToLevel(profile?.user?.xp ?? 0).level, [profile]); const rank = useMemo(() => getRank(level), [level]); const isElite = level >= 91; @@ -183,17 +207,26 @@ export default function FriendProfileScreen({ route, navigation }) { {/* ── Action sociale : Secouer (uniquement si coéquipier de streak) ── */} {sharedGroup && ( - + {shaking ? - : } + : } Coéquipier de streak - Secoue {profile.user.pseudo} s'il n'a pas encore fait sa séance + + {alreadyShakenToday + ? `Tu as déjà secoué ${profile.user.pseudo} aujourd'hui` + : `Secoue ${profile.user.pseudo} s'il n'a pas encore fait sa séance`} + - + {!alreadyShakenToday && } )} @@ -204,21 +237,13 @@ export default function FriendProfileScreen({ route, navigation }) {
- {/* ── Records personnels ── */} + {/* ── Records personnels (même composant que sur son propre profil) ── */}
- {profile.records.length === 0 ? ( - - - Aucun record enregistré pour le moment. - - ) : profile.records.map((r, i) => ( - - - {r.exercice} - {r.maxPoids} kg × {r.maxReps} - - ))} +
@@ -274,6 +299,10 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(255,77,77,0.28)', borderRadius: 14, paddingVertical: 12, paddingHorizontal: 14, }, + shakeBannerDisabled: { + backgroundColor: 'rgba(255,255,255,0.03)', + borderColor: Colors.borderSubtle, + }, shakeIconBox: { width: 36, height: 36, borderRadius: 11, backgroundColor: 'rgba(255,77,77,0.12)', @@ -293,15 +322,4 @@ const styles = StyleSheet.create({ borderRadius: 18, padding: 14, overflow: 'hidden', }, - recordRow: { - flexDirection: 'row', alignItems: 'center', - paddingVertical: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: Colors.borderSubtle, - }, - recordName: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600', marginRight: 8 }, - recordValue: { color: Colors.primary, fontSize: 13, fontWeight: '800' }, - - emptyRecords: { alignItems: 'center', paddingVertical: 20, gap: 8 }, - emptyTxt: { color: Colors.textMuted, fontSize: 12.5, textAlign: 'center' }, }); diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index 2a871a2..e6ecdb6 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -11,12 +11,16 @@ import { useUser } from '../../context/UserContext'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import ConfirmModal from '../../components/common/ConfirmModal'; import FriendshipLevelUpModal from '../../components/social/FriendshipLevelUpModal'; +import FriendPreviewModal from '../../components/social/FriendPreviewModal'; +import AddFriendModal from '../../components/social/AddFriendModal'; import { searchUsers, sendFriendRequest, acceptFriendRequest, declineFriendRequest, + cancelFriendRequest, removeFriend, getFriendsList, getPendingRequests, getLeaderboard, getExerciseLeaderboard, getMyGroup, inviteToGroup, respondToGroupInvite, shakeMember, checkGroupStreak, leaveGroup, } from '../../services/social.service'; import { MAJOR_EXERCISES } from '../../data/majorExercises'; +import ExercisePickerModal from '../../components/social/ExercisePickerModal'; // Podium / classements : positions 1-3 affichées en médaille colorée plutôt // qu'en emoji 🥇🥈🥉. @@ -40,28 +44,35 @@ const SEGMENTS = [ export default function SocialScreen({ navigation }) { const { showToast } = useToast(); - const { refetch: refetchUser } = useUser(); + const { user, refetch: refetchUser } = useUser(); const { addBonusXp } = useWorkoutLogs(); const [segment, setSegment] = useState('friends'); // ── Données ── const [friends, setFriends] = useState([]); const [pending, setPending] = useState([]); + const [sentRequests, setSentRequests] = useState([]); const [leaderboard, setLeaderboard] = useState([]); const [group, setGroup] = useState(null); const [groupInvites, setGroupInvites] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - // ── Recherche ── - const [query, setQuery] = useState(''); - const [results, setResults] = useState(null); // null = pas de recherche active - const [searching, setSearching] = useState(false); - const searchTimer = useRef(null); + // ── Ajout d'ami par tag exact "Pseudo#1234" (Section III) ── + // Point d'entrée unique : bouton "+ Ajouter un ami" → AddFriendModal + // (pseudo + # séparés, affiche aussi mon propre tag) → un résultat trouvé + // ferme AddFriendModal et ouvre la carte Preview (FriendPreviewModal). + const [addFriendVisible, setAddFriendVisible] = useState(false); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(''); + const [previewResult, setPreviewResult] = useState(null); // ── Confirmation quitter le groupe ── const [leaveConfirmVisible, setLeaveConfirmVisible] = useState(false); + // ── Confirmation retrait d'un ami ── + const [removeFriendTarget, setRemoveFriendTarget] = useState(null); // { friendshipId, pseudo } + // ── Célébration montée de niveau d'amitié ────────────────────────────────── // Comparaison du niveau d'amitié de chaque ami entre deux loadAll() : toute // hausse détectée (déclenchée par une validation de streak de groupe) est @@ -95,6 +106,7 @@ export default function SocialScreen({ navigation }) { setFriends(incomingFriends); setPending(pendingRes.requests ?? []); + setSentRequests(pendingRes.sent ?? []); setLeaderboard(boardRes.leaderboard ?? []); setGroup(groupRes.group ?? null); setGroupInvites(groupRes.invites ?? []); @@ -120,33 +132,34 @@ export default function SocialScreen({ navigation }) { const onRefresh = () => { setRefreshing(true); loadAll(); }; - // ── Recherche débouncée (400 ms) ── - const onQueryChange = (text) => { - setQuery(text); - if (searchTimer.current) clearTimeout(searchTimer.current); - if (text.trim().length < 2) { setResults(null); return; } - searchTimer.current = setTimeout(async () => { - setSearching(true); - try { - const res = await searchUsers(text.trim()); - setResults(res.results ?? []); - } catch (_) { - setResults([]); - } finally { - setSearching(false); + // Recherche déclenchée depuis AddFriendModal, avec le tag déjà construit à + // partir des 2 champs séparés (pseudo + # à 4 chiffres) — jamais au fil de + // la frappe, pour éviter d'ajouter la mauvaise personne. + const onSearchTag = async (fullTag) => { + setSearchError(''); + setSearching(true); + try { + const res = await searchUsers(fullTag); + const found = (res.results ?? [])[0]; + if (found) { + setPreviewResult(found); + setAddFriendVisible(false); + } else { + setSearchError('Aucun athlète ne correspond à ce tag.'); } - }, 400); + } catch (error) { + setSearchError(error?.data?.message || 'Recherche impossible.'); + } finally { + setSearching(false); + } }; - useEffect(() => () => searchTimer.current && clearTimeout(searchTimer.current), []); - // ── Actions amis ── const doAction = async (fn, successMsg) => { try { await fn(); if (successMsg) showToast(successMsg, 'success'); loadAll(); - if (query.trim().length >= 2) onQueryChange(query); // rafraîchit la recherche } catch (error) { if (error.isSessionExpired) return; showToast(error.data?.message || 'Action impossible.', 'error'); @@ -196,13 +209,12 @@ export default function SocialScreen({ navigation }) { doAction(() => sendFriendRequest(id), 'Invitation envoyée')} + sentRequests={sentRequests} + onOpenAddFriend={() => setAddFriendVisible(true)} onAccept={(id) => doAction(() => acceptFriendRequest(id), 'Vous êtes maintenant amis !')} onDecline={(id) => doAction(() => declineFriendRequest(id))} + onCancelSent={(id) => doAction(() => cancelFriendRequest(id), 'Demande annulée.')} + onRemoveFriend={(friendshipId, pseudo) => setRemoveFriendTarget({ friendshipId, pseudo })} onOpenProfile={(friend, friendshipLevel) => navigation.navigate('FriendProfile', { friendId: friend._id, pseudo: friend.pseudo, friendshipLevel })} /> @@ -213,6 +225,7 @@ export default function SocialScreen({ navigation }) { {segment === 'group' && ( doAction(() => inviteToGroup(ids, name), 'Demande de Streak de Groupe envoyée')} @@ -267,6 +280,22 @@ export default function SocialScreen({ navigation }) { onCancel={() => setLeaveConfirmVisible(false)} /> + { + const target = removeFriendTarget; + setRemoveFriendTarget(null); + doAction(() => removeFriend(target.friendshipId), `${target.pseudo} a été retiré de tes amis.`); + }} + onCancel={() => setRemoveFriendTarget(null)} + /> + setActiveLevelUp(null)} /> + + { setAddFriendVisible(false); setSearchError(''); }} + /> + + { + await doAction(() => sendFriendRequest(previewResult.user._id), 'Invitation envoyée'); + setPreviewResult((prev) => prev && { ...prev, relationStatus: 'pending_sent' }); + }} + onClose={() => setPreviewResult(null)} + />
); } @@ -294,52 +342,15 @@ export default function SocialScreen({ navigation }) { // ═══ Segment Amis ═════════════════════════════════════════════════════════════ function FriendsSegment({ - friends, pending, query, results, searching, - onQueryChange, onSend, onAccept, onDecline, onOpenProfile, + friends, pending, sentRequests, onOpenAddFriend, onAccept, onDecline, onCancelSent, onRemoveFriend, onOpenProfile, }) { return ( <> - {/* ── Recherche ── */} - - - - {searching && } - - - {results !== null && ( - <> - RÉSULTATS - {results.length === 0 && !searching ? ( - Aucun athlète trouvé. - ) : results.map((r, i) => ( - - - {r.relationStatus === 'none' && ( - onSend(r.user._id)} /> - )} - {r.relationStatus === 'pending_sent' && Envoyée ✓} - {r.relationStatus === 'pending_received' && ( - onAccept(r.requestId)} /> - )} - {r.relationStatus === 'accepted' && ( - - - Ami - - )} - - - ))} - - )} + {/* ── Point d'entrée unique pour ajouter un ami (Section III) ── */} + + + Ajouter un nouvel ami + {/* ── Demandes reçues ── */} {pending.length > 0 && ( @@ -356,6 +367,24 @@ function FriendsSegment({ )} + {/* ── Demandes envoyées, en attente de réponse ── */} + {(sentRequests ?? []).length > 0 && ( + <> + DEMANDES ENVOYÉES + {sentRequests.map((req, i) => ( + + + + + En attente + + onCancelSent(req._id)} /> + + + ))} + + )} + {/* ── Mes amis ── */} MES AMIS ({friends.length}) {friends.length === 0 ? ( @@ -368,6 +397,13 @@ function FriendsSegment({ onOpenProfile(f.user, f.friendshipLevel)}> + onRemoveFriend(f.friendshipId, f.user.pseudo)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={{ marginLeft: 6 }} + > + + @@ -445,7 +481,7 @@ function XpLeaderboard({ leaderboard }) { {rest.map((entry, i) => ( - #{entry.position} + #{entry.position} {entry.user.pseudo}{entry.isMe ? ' (moi)' : ''} @@ -465,6 +501,7 @@ function RecordsLeaderboard() { const [exercise, setExercise] = useState(MAJOR_EXERCISES[0].name); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); + const [pickerVisible, setPickerVisible] = useState(false); useEffect(() => { let cancelled = false; @@ -478,8 +515,16 @@ function RecordsLeaderboard() { return ( <> - {/* ── Choix de l'exercice ── */} + {/* ── Suggestions rapides + accès au catalogue complet (~100+ exercices) ── */} + setPickerVisible(true)} + activeOpacity={0.8} + > + + Tous les exercices + {MAJOR_EXERCISES.map((exo) => { const active = exo.name === exercise; return ( @@ -495,6 +540,13 @@ function RecordsLeaderboard() { })} + setPickerVisible(false)} + /> + {loading ? ( ) : rows.length === 0 ? ( @@ -554,7 +606,7 @@ function PodiumColumn({ entry, height, color, delay }) { // ═══ Segment Groupe ═══════════════════════════════════════════════════════════ -function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, onCheckStreak, onLeaveGroup }) { +function GroupSegment({ group, myId, invites, friends, onInvite, onRespond, onShake, onCheckStreak, onLeaveGroup }) { const [selectedIds, setSelectedIds] = useState([]); const [groupName, setGroupName] = useState(''); @@ -580,7 +632,7 @@ function GroupSegment({ group, invites, friends, onInvite, onRespond, onShake, o ))} {group ? ( - + ) : ( <> CRÉER UN GROUPE DE STREAK (MAX 5) @@ -647,7 +699,7 @@ const REGULARITY_SCALE = [0, 7, 14, 21, 28, 35, 42, 49, 56].map((days) => ({ multiplier: 1 + Math.min(0.6, 0.08 * Math.floor(days / 7)), })); -function GroupCard({ group, onShake, onCheckStreak, onLeaveGroup }) { +function GroupCard({ group, myId, onShake, onCheckStreak, onLeaveGroup }) { const flame = useRef(new Animated.Value(1)).current; const [showScale, setShowScale] = useState(false); @@ -743,18 +795,27 @@ function GroupCard({ group, onShake, onCheckStreak, onLeaveGroup }) { MEMBRES ({memberCount}/5) - {(group.members ?? []).map((m) => ( - - onShake(group._id, m._id, m.pseudo)} - activeOpacity={0.8} - > - - Secouer - - - ))} + {(group.members ?? []).map((m) => { + const isMe = myId && m._id === myId; + const alreadyShaken = (group.shakenTodayByMe ?? []).includes(m._id); + return ( + + {!isMe && ( + onShake(group._id, m._id, m.pseudo)} + activeOpacity={alreadyShaken ? 1 : 0.8} + disabled={alreadyShaken} + > + + + {alreadyShaken ? 'Secoué' : 'Secouer'} + + + )} + + ); + })} onCheckStreak(group._id)} activeOpacity={0.85}> @@ -888,14 +949,17 @@ const styles = StyleSheet.create({ letterSpacing: 0.8, marginTop: 20, marginBottom: 8, marginLeft: 4, }, - // ── Recherche ── - searchBox: { - flexDirection: 'row', alignItems: 'center', gap: 8, - backgroundColor: 'rgba(255,255,255,0.05)', - borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', - borderRadius: 12, paddingHorizontal: 12, height: 44, + // ── Ajout d'ami ── + addFriendBtn: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + backgroundColor: Colors.primary, + borderRadius: 13, height: 48, marginBottom: 4, + shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.35, shadowRadius: 10, elevation: 5, }, - searchInput: { flex: 1, color: Colors.textPrimary, fontSize: 14 }, + addFriendBtnTxt: { color: '#fff', fontSize: 14.5, fontWeight: '700' }, + sentTagRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginRight: 8 }, + sentTag: { color: Colors.textMuted, fontSize: 12, fontWeight: '600' }, // ── Lignes utilisateur ── userRow: { @@ -920,9 +984,6 @@ const styles = StyleSheet.create({ userPseudo: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' }, userMeta: { color: Colors.textMuted, fontSize: 11.5, marginTop: 1 }, userActions:{ flexDirection: 'row', alignItems: 'center', gap: 6 }, - pendingTag: { color: Colors.textMuted, fontSize: 12, fontWeight: '600' }, - friendTagRow: { flexDirection: 'row', alignItems: 'center', gap: 4 }, - friendTag: { color: Colors.textMuted, fontSize: 12, fontWeight: '700' }, hearts: { flexDirection: 'row', alignItems: 'center' }, smallBtn: { @@ -953,7 +1014,7 @@ const styles = StyleSheet.create({ }, boardRowMe: { borderColor: 'rgba(254,116,57,0.45)', backgroundColor: 'rgba(254,116,57,0.06)' }, boardPos: { width: 36 }, - boardPosTxt: { color: Colors.textMuted, fontSize: 13, fontWeight: '800' }, + boardPosTxt: { color: Colors.textPrimary, fontSize: 13, fontWeight: '800' }, boardPseudo: { flex: 1, color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' }, boardXp: { color: Colors.textSecondary, fontSize: 12.5, fontWeight: '700' }, boardKg: { color: Colors.primary, fontSize: 13.5, fontWeight: '800' }, @@ -979,6 +1040,7 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255,255,255,0.04)', }, exoChipActive: { backgroundColor: Colors.primary, borderColor: Colors.primary }, + exoChipMore: { flexDirection: 'row', alignItems: 'center', borderColor: `${Colors.primary}50`, backgroundColor: `${Colors.primary}12` }, exoChipTxt: { color: Colors.textSecondary, fontSize: 12, fontWeight: '600' }, exoChipTxtActive: { color: '#fff' }, recordsLoading: { paddingVertical: 30, alignItems: 'center' }, @@ -1060,7 +1122,12 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(255,77,77,0.40)', borderRadius: 9, paddingHorizontal: 10, height: 32, justifyContent: 'center', }, + shakeBtnDisabled: { + backgroundColor: 'rgba(255,255,255,0.04)', + borderColor: Colors.borderSubtle, + }, shakeTxt: { color: Colors.error, fontSize: 11.5, fontWeight: '800' }, + shakeTxtDisabled: { color: Colors.textMuted }, leaveBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', diff --git a/front/src/screens/Stats/StatsScreen.js b/front/src/screens/Stats/StatsScreen.js index fed316d..18241ac 100644 --- a/front/src/screens/Stats/StatsScreen.js +++ b/front/src/screens/Stats/StatsScreen.js @@ -7,14 +7,18 @@ import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from '@react-navigation/native'; import { Colors } from '../../constants/theme'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; +import { useUser } from '../../context/UserContext'; import { aggregateGlobal } from '../../services/stats.service'; +import { getWeightHistory } from '../../services/weight.service'; import PeriodSegmentedControl from '../../components/stats/PeriodSegmentedControl'; import VolumeBarChart from '../../components/stats/VolumeBarChart'; import MuscleDistributionPieChart from '../../components/stats/MuscleDistributionPieChart'; +import WeightProgressChart from '../../components/stats/WeightProgressChart'; import WorkoutCalendar from '../../components/stats/WorkoutCalendar'; import XPProgressBar from '../../components/stats/XPProgressBar'; import WorkoutHistoryList from '../../components/stats/WorkoutHistoryList'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; +import WeightEntryModal from '../../components/common/WeightEntryModal'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; import { MOCK_TUTORIAL_LOGS } from '../../data/mockTutorialStats'; @@ -25,12 +29,28 @@ const TABS = [ export default function StatsScreen({ navigation }) { const { sessionLogs: realLogs, totalXP, remove } = useWorkoutLogs(); + const { user } = useUser(); const handleDelete = useCallback(async (id) => { try { await remove(id); } catch (e) { Alert.alert('Erreur', e?.message || 'Suppression impossible'); } }, [remove]); + + // ─── Suivi de poids (Section VI) ───────────────────────────────────────── + const [weightHistory, setWeightHistory] = useState([]); + const [weightEntryVisible, setWeightEntryVisible] = useState(false); + + const loadWeightHistory = useCallback(async () => { + try { + const res = await getWeightHistory(); + setWeightHistory(Array.isArray(res.history) ? res.history : []); + } catch (_) { + // Best-effort — un historique de poids manquant ne doit jamais bloquer l'écran. + } + }, []); + + useFocusEffect(useCallback(() => { loadWeightHistory(); }, [loadWeightHistory])); const [tab, setTab] = useState('performance'); const [period, setPeriod] = useState('month'); @@ -169,6 +189,18 @@ export default function StatsScreen({ navigation }) {
+ + + setWeightEntryVisible(true)} + activeOpacity={0.85} + > + + Ajouter une pesée + + + @@ -207,6 +239,12 @@ export default function StatsScreen({ navigation }) { {activeChapterId === 'stats' && ( )} + + setWeightEntryVisible(false)} + onSaved={loadWeightHistory} + /> ); } @@ -273,6 +311,13 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)', }, cardTitle: { color: Colors.textPrimary, fontSize: 14, fontWeight: '800', marginBottom: 12 }, + addWeightBtn: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + marginTop: 12, height: 40, borderRadius: 11, + backgroundColor: `${Colors.primary}14`, + borderWidth: 1, borderColor: `${Colors.primary}40`, + }, + addWeightBtnTxt: { color: Colors.primary, fontSize: 13, fontWeight: '700' }, emptyText: { color: Colors.textMuted, fontSize: 13, textAlign: 'center', lineHeight: 20, paddingVertical: 12 }, diff --git a/front/src/services/debug.service.js b/front/src/services/debug.service.js index 4e2f49f..f93a260 100644 --- a/front/src/services/debug.service.js +++ b/front/src/services/debug.service.js @@ -47,3 +47,45 @@ export async function simulateBirthday() { const res = await API.post('/debug/godmode/simulate-birthday'); return res.data; } + +// ─── Vague 1 : groupe, météo, activité, Hall of Shame, secouer ─────────────── + +// Crée (ou régénère) un groupe de streak avec 3 coéquipiers factices, un par +// statut de Météo des séances (Prêt/Actif/Validé) — teste weatherStatus, le +// multiplicateur de groupe et le bouton Secouer sans second appareil. +export async function simulateGroup() { + const res = await API.post('/debug/godmode/simulate-group'); + return res.data; +} + +// Publie un ActivityEvent factice au nom d'un coéquipier (jamais soi-même) — +// teste ActivityFeedModal et les réactions. type: 'pr_broken' | 'chest_legendary'. +export async function simulateActivityEvent(type) { + const res = await API.post('/debug/godmode/simulate-activity-event', type ? { type } : {}); + return res.data; +} + +// Recule lastValidatedDate du groupe pour déclencher le Hall of Shame au +// prochain chargement de l'onglet Groupe. +export async function simulateStreakBreak() { + const res = await API.post('/debug/godmode/simulate-streak-break'); + return res.data; +} + +// Envoie une vraie notification push au token Expo de l'utilisateur connecté +// (même texte troll que le vrai bouton Secouer) — vérifie l'infra push de +// bout en bout sans second compte/appareil. +export async function simulateShakeSelf() { + const res = await API.post('/debug/godmode/simulate-shake-self'); + return res.data; +} + +// ─── Vague 2 : tag Discord, ajout d'ami ─────────────────────────────────────── + +// Crée un compte de test PAS déjà ami (contrairement à generateMockSocial) — +// seul moyen de tester en solo le parcours complet "Ajouter un ami" : +// recherche par tag exact, carte Preview, envoi réel de la demande. +export async function simulateSearchableFriend() { + const res = await API.post('/debug/godmode/simulate-searchable-friend'); + return res.data; +} diff --git a/front/src/services/profile.service.js b/front/src/services/profile.service.js index f67485f..a5314ec 100644 --- a/front/src/services/profile.service.js +++ b/front/src/services/profile.service.js @@ -14,6 +14,13 @@ export async function updateShowcase(achievementIds) { return res.data; } +// Met à jour les records d'exercices mis en avant sur le profil (max 6) — +// affichés identiquement sur son propre profil et le profil vu par les amis. +export async function updateRecordsShowcase(exerciseNames) { + const res = await API.put('/users/me/records-showcase', { exerciseNames }); + return res.data; +} + // Enregistre (ou efface, si null) le token Expo Push de l'appareil courant — // nécessaire pour recevoir les notifications réellement envoyées par un // autre appareil (bouton Secouer, réactions du flux d'activité...). diff --git a/front/src/services/social.service.js b/front/src/services/social.service.js index 3055bcf..3c7c610 100644 --- a/front/src/services/social.service.js +++ b/front/src/services/social.service.js @@ -22,6 +22,18 @@ export async function declineFriendRequest(requestId) { return res.data; } +// Annule une demande d'ami que J'AI ENVOYÉE (pendant du "refuser" côté destinataire). +export async function cancelFriendRequest(requestId) { + const res = await API.delete(`/friends/request/${requestId}`); + return res.data; +} + +// Retire un ami (rompt une amitié acceptée) — l'un ou l'autre membre peut le faire. +export async function removeFriend(friendshipId) { + const res = await API.delete(`/friends/${friendshipId}`); + return res.data; +} + export async function getFriendsList() { const res = await API.get('/friends/list'); return res.data; @@ -48,6 +60,13 @@ export async function getExerciseLeaderboard(exercise) { return res.data; } +// Tous mes records (un par exercice déjà pratiqué) — alimente le sélecteur +// "mettre en avant jusqu'à 6 records" du profil. +export async function getMyRecords() { + const res = await API.get('/exercises/my-records'); + return res.data; +} + // ─── Groupes de Streak (Brique IV) ──────────────────────────────────────────── export async function getMyGroup() { diff --git a/front/src/services/weight.service.js b/front/src/services/weight.service.js new file mode 100644 index 0000000..5fbf568 --- /dev/null +++ b/front/src/services/weight.service.js @@ -0,0 +1,15 @@ +import API from '../api/api'; + +// ─── Suivi de poids (Section VI) ────────────────────────────────────────────── + +export async function getWeightHistory() { + const res = await API.get('/weight/history'); + return res.data; +} + +// `date` optionnelle (ISO string) — permet une saisie rétroactive depuis la +// modale de rappel hebdomadaire sans forcer "aujourd'hui". +export async function logWeight(weight, date) { + const res = await API.post('/weight', date ? { weight, date } : { weight }); + return res.data; +} From f52fc8837eb6020511987dd9c2bb375b79c8e3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 9 Jul 2026 14:34:27 +0200 Subject: [PATCH 18/31] =?UTF-8?q?feat(multiplayer):=20lobby=20multi=20sync?= =?UTF-8?q?hrone,=20bonus=20XP,=20troph=C3=A9es=20sociaux,=20=C3=A9radicat?= =?UTF-8?q?ion=20des=2033=20alertes=20et=20filtre=20anti-injures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/app.js | 2 + back/config/env.js | 6 + back/controllers/auth.controller.js | 11 + back/controllers/debug.controller.js | 78 ++- back/controllers/groupStreak.controller.js | 4 +- back/controllers/reward.controller.js | 40 ++ back/controllers/workoutLobby.controller.js | 384 +++++++++++++++ back/data/localTrophyCatalog.js | 24 +- back/models/User.js | 14 + back/models/WorkoutLobby.js | 66 +++ back/package-lock.json | 192 +++++++- back/package.json | 1 + back/routes/auth.routes.js | 6 + back/routes/debug.routes.js | 3 + back/routes/workoutLobby.routes.js | 20 + back/services/auth.service.js | 81 ++++ back/services/email.service.js | 2 +- back/tests/debug.test.js | 47 ++ back/tests/googleAuth.test.js | 110 +++++ back/tests/profanityFilter.test.js | 68 +++ back/tests/workoutLobby.test.js | 428 +++++++++++++++++ back/utils/profanityFilter.js | 64 +++ back/validators/auth.validator.js | 6 + back/validators/workoutLobby.validator.js | 9 + front/src/components/cards/ExerciseCard.js | 54 ++- .../src/components/common/ActionSheetModal.js | 94 ++++ front/src/components/common/ErrorBoundary.js | 2 +- front/src/components/common/InfoModal.js | 107 +++++ .../components/home/RecoveryRitualsCard.js | 6 +- .../components/profile/AchievementShowcase.js | 4 + .../src/components/profile/BirthdatePicker.js | 2 +- .../components/profile/PersonalRecordsList.js | 2 +- front/src/components/profile/StreakBadge.js | 2 +- .../components/social/FriendPreviewModal.js | 2 +- front/src/components/stats/PRCard.js | 6 +- .../components/stats/WorkoutHistoryList.js | 32 +- .../src/components/workouts/ExerciseHeader.js | 18 +- .../workouts/InlineExerciseBlock.js | 37 +- .../components/workouts/LobbyInviteCheck.js | 69 +++ .../components/workouts/LobbyInviteModal.js | 90 ++++ .../components/workouts/LobbyMembersBar.js | 56 +++ .../workouts/LobbyWaitingOverlay.js | 83 ++++ .../components/workouts/MultiLobbyModal.js | 443 ++++++++++++++++++ .../src/components/workouts/MultiLootModal.js | 95 ++++ .../components/workouts/WorkoutRecapModal.js | 4 +- front/src/data/trophyCatalog.js | 24 +- front/src/data/tutorialChapters.js | 4 +- front/src/navigation/index.js | 5 +- front/src/navigation/navigationRef.js | 12 + .../src/screens/Auth/ForgotPasswordScreen.js | 4 +- front/src/screens/Auth/RegisterScreen.js | 88 ++++ front/src/screens/Home/HomeScreen.js | 2 +- .../src/screens/Profile/EditProfileScreen.js | 6 +- front/src/screens/Profile/ProfileScreen.js | 2 +- front/src/screens/Profile/SettingsScreen.js | 103 +++- front/src/screens/Profile/TrophyRoomScreen.js | 6 +- front/src/screens/Social/SocialScreen.js | 4 +- front/src/screens/Stats/StatsScreen.js | 85 +++- .../screens/Workouts/CustomExercisesScreen.js | 53 ++- .../screens/Workouts/EditExerciseScreen.js | 62 ++- .../screens/Workouts/ExerciseStatsScreen.js | 6 +- .../Workouts/ManualWorkoutCreatorScreen.js | 38 +- .../screens/Workouts/WorkoutBuilderScreen.js | 23 +- .../src/screens/Workouts/WorkoutListScreen.js | 118 ++++- front/src/screens/Workouts/WorkoutScreen.js | 156 +++++- front/src/services/debug.service.js | 11 + front/src/services/lobby.service.js | 38 ++ 67 files changed, 3466 insertions(+), 258 deletions(-) create mode 100644 back/controllers/workoutLobby.controller.js create mode 100644 back/models/WorkoutLobby.js create mode 100644 back/routes/workoutLobby.routes.js create mode 100644 back/tests/googleAuth.test.js create mode 100644 back/tests/profanityFilter.test.js create mode 100644 back/tests/workoutLobby.test.js create mode 100644 back/utils/profanityFilter.js create mode 100644 back/validators/workoutLobby.validator.js create mode 100644 front/src/components/common/ActionSheetModal.js create mode 100644 front/src/components/common/InfoModal.js create mode 100644 front/src/components/workouts/LobbyInviteCheck.js create mode 100644 front/src/components/workouts/LobbyInviteModal.js create mode 100644 front/src/components/workouts/LobbyMembersBar.js create mode 100644 front/src/components/workouts/LobbyWaitingOverlay.js create mode 100644 front/src/components/workouts/MultiLobbyModal.js create mode 100644 front/src/components/workouts/MultiLootModal.js create mode 100644 front/src/navigation/navigationRef.js create mode 100644 front/src/services/lobby.service.js diff --git a/back/app.js b/back/app.js index 28a36ed..776ed28 100644 --- a/back/app.js +++ b/back/app.js @@ -17,6 +17,7 @@ const referralRoutes = require("./routes/referral.routes"); const debugRoutes = require("./routes/debug.routes"); const activityRoutes = require("./routes/activity.routes"); const weightRoutes = require("./routes/weight.routes"); +const workoutLobbyRoutes = require("./routes/workoutLobby.routes"); // --- Importation des middlewares --- const errorMiddleware = require("./middleware/error.middleware"); @@ -98,6 +99,7 @@ app.use("/api/referral", referralRoutes); app.use("/api/debug", debugRoutes); app.use("/api/activity", activityRoutes); app.use("/api/weight", weightRoutes); +app.use("/api/lobby", workoutLobbyRoutes); // --- Gestion des erreurs --- // Route 404 diff --git a/back/config/env.js b/back/config/env.js index 05f72a4..aaf5015 100644 --- a/back/config/env.js +++ b/back/config/env.js @@ -15,6 +15,12 @@ const config = { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, + + // ── Google OAuth (Section VIII) ───────────────────────────────────────────── + // Non requis pour démarrer le serveur (contrairement à mongoUri/jwtSecret) : + // tant que la variable n'est pas définie, /api/auth/google répond 501 + // "non configuré" au lieu de planter tout le serveur au démarrage. + googleClientId: process.env.GOOGLE_CLIENT_ID || null, }; if (!config.mongoUri) { diff --git a/back/controllers/auth.controller.js b/back/controllers/auth.controller.js index 861904b..01bbdaf 100644 --- a/back/controllers/auth.controller.js +++ b/back/controllers/auth.controller.js @@ -30,6 +30,17 @@ exports.loginUser = async (req, res, next) => { } }; +// ── Connexion Google OAuth (Section VIII) ───────────────────────────────────── +exports.googleLogin = async (req, res, next) => { + try { + const { idToken } = req.body; + const data = await authService.googleLogin(idToken); + res.status(200).json({ success: true, message: "Connexion Google réussie.", ...data }); + } catch (error) { + next(error); + } +}; + // ── Vérification email ──────────────────────────────────────────────────────── exports.verifyEmailUser = async (req, res, next) => { try { diff --git a/back/controllers/debug.controller.js b/back/controllers/debug.controller.js index d943d8c..ea77333 100644 --- a/back/controllers/debug.controller.js +++ b/back/controllers/debug.controller.js @@ -6,6 +6,7 @@ const User = require('../models/User'); const Friendship = require('../models/Friendship'); const StreakGroup = require('../models/StreakGroup'); const Workout = require('../models/Workout'); +const WorkoutLobby = require('../models/WorkoutLobby'); const { xpForLevel, getRankForLevel } = require('../utils/levelHelpers'); const { addItemAtomic, addUniqueItemOnce } = require('../services/inventory.service'); const { checkAndUnlockAchievements } = require('./reward.controller'); @@ -592,7 +593,7 @@ exports.simulateActivityEvent = async (req, res, next) => { const group = await StreakGroup.findOne({ members: myId }); if (!group) { - return next(createError('Aucun groupe — utilise "Simuler un groupe" avant de tester le flux d\'activité.', 400)); + return next(createError('Aucun groupe - utilise "Simuler un groupe" avant de tester le flux d\'activité.', 400)); } const otherMemberId = group.members.find((m) => m.toString() !== myId); @@ -642,7 +643,7 @@ exports.simulateStreakBreak = async (req, res, next) => { const myId = req.user.id; const group = await StreakGroup.findOne({ members: myId }); if (!group) { - return next(createError('Aucun groupe — utilise "Simuler un groupe" avant de tester le Hall of Shame.', 400)); + return next(createError('Aucun groupe - utilise "Simuler un groupe" avant de tester le Hall of Shame.', 400)); } const twoDaysAgo = new Date(); @@ -655,7 +656,7 @@ exports.simulateStreakBreak = async (req, res, next) => { return res.status(200).json({ success: true, - message: 'Rupture de streak simulée — recharge l\'onglet Groupe pour voir le Hall of Shame.', + message: 'Rupture de streak simulée - recharge l\'onglet Groupe pour voir le Hall of Shame.', }); } catch (err) { next(err); @@ -677,7 +678,7 @@ exports.simulateShakeSelf = async (req, res, next) => { try { const user = await User.findById(req.user.id).select('pushToken'); if (!user?.pushToken) { - return next(createError("Aucun token push enregistré sur ce compte — ouvre l'app avec les notifications autorisées d'abord.", 400)); + return next(createError("Aucun token push enregistré sur ce compte - ouvre l'app avec les notifications autorisées d'abord.", 400)); } const trollMessage = SHAKE_TROLL_MESSAGES[Math.floor(Math.random() * SHAKE_TROLL_MESSAGES.length)]; @@ -690,7 +691,7 @@ exports.simulateShakeSelf = async (req, res, next) => { return res.status(200).json({ success: true, pushed, - message: pushed ? 'Notification envoyée à ton appareil.' : "Échec d'envoi — le token est peut-être périmé.", + message: pushed ? 'Notification envoyée à ton appareil.' : "Échec d'envoi - le token est peut-être périmé.", }); } catch (err) { next(err); @@ -764,3 +765,70 @@ exports.simulateSearchableFriend = async (req, res, next) => { next(err); } }; + +// ───────────────────────────────────────────────────────────────────────────── +// simulateLobbyInvite POST /api/debug/godmode/simulate-lobby-invite +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : crée un coéquipier factice `isTestBot: true` et un lobby + * Multi dont il est le créateur, puis envoie une VRAIE notification push à + * l'appelant (data: { type: 'lobby_invite', lobbyId, fromPseudo }) — permet de + * tester en solo le parcours complet de la Lobby Multi (réception d'une + * invitation → popup → rejoindre → prêt → séance → fin), le bot étant + * automatiquement auto-progressé en miroir du statut de l'appelant (voir + * autoProgressBots dans workoutLobby.controller.js), sans second appareil. + * + * Idempotent : le bot et le lobby précédemment créés par cet outil pour CET + * utilisateur (namespacés par son ObjectId) sont supprimés avant recréation, + * pour ne jamais empiler des invitations fantômes. + */ +exports.simulateLobbyInvite = async (req, res, next) => { + try { + const myId = req.user.id; + const emailPrefix = `mock-lobbybot-${myId}-`; + + const previousBots = await User.find({ email: { $regex: `^${emailPrefix}` } }).select('_id'); + const previousIds = previousBots.map((u) => u._id); + if (previousIds.length > 0) { + await WorkoutLobby.deleteMany({ 'members.user': { $in: previousIds } }); + await User.deleteMany({ _id: { $in: previousIds } }); + } + + const passwordHash = await bcrypt.hash(crypto.randomBytes(24).toString('hex'), MOCK_PASSWORD_ROUNDS); + const bot = await User.create({ + pseudo: 'CoequipierBot', + email: `${emailPrefix}${Date.now()}@athly.dev`, + password: passwordHash, + isVerified: true, + isTestBot: true, + level: 20, + xp: xpForLevel(20), + rank: getRankForLevel(20), + }); + + const lobby = await WorkoutLobby.create({ + creatorId: bot._id, + members: [{ user: bot._id, status: 'waiting' }], + memberCount: 1, + }); + + const pushed = await sendPushToUser(myId, { + title: 'Invitation Multi', + body: `${bot.pseudo} t'invite à réaliser une séance ensemble !`, + data: { type: 'lobby_invite', lobbyId: lobby._id.toString(), fromPseudo: bot.pseudo }, + }); + + return res.status(201).json({ + success: true, + message: pushed + ? 'Invitation simulée envoyée à ton appareil.' + : "Lobby créé, mais l'envoi push a échoué (token manquant ou périmé) - utilise directement lobbyId.", + lobbyId: lobby._id, + fromPseudo: bot.pseudo, + pushed, + }); + } catch (err) { + next(err); + } +}; diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index 059c802..7665f30 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -500,7 +500,7 @@ exports.shakeMember = async (req, res, next) => { }); if (workoutDone) { - return next(createError("Ce membre a déjà validé sa séance aujourd'hui — inutile de le secouer !", 422)); + return next(createError("Ce membre a déjà validé sa séance aujourd'hui - inutile de le secouer !", 422)); } // Limite 1 secousse par jour civil et par cible — évite le harcèlement @@ -509,7 +509,7 @@ exports.shakeMember = async (req, res, next) => { s.from.toString() === myId && s.to.toString() === memberId && s.date >= todayStart && s.date < tomorrow, ); if (alreadyShakenToday) { - return next(createError("Tu as déjà secoué cette personne aujourd'hui — reviens demain !", 422)); + return next(createError("Tu as déjà secoué cette personne aujourd'hui - reviens demain !", 422)); } const target = await User.findById(memberId).select('pseudo'); diff --git a/back/controllers/reward.controller.js b/back/controllers/reward.controller.js index e9bf1dc..84a9f38 100644 --- a/back/controllers/reward.controller.js +++ b/back/controllers/reward.controller.js @@ -114,6 +114,22 @@ const ACHIEVEMENT_CATALOG = { category: 'social', hidden: false, }, + + // ── Lobby Multi (Section VII) ───────────────────────────────────────────── + FIRST_MULTI_SESSION: { + id: 'FIRST_MULTI_SESSION', + name: 'Duo de Choc', + description: "Vous avez terminé votre première séance en Multi.", + category: 'social', + hidden: false, + }, + MULTI_SQUAD_FULL: { + id: 'MULTI_SQUAD_FULL', + name: 'Escouade Complète', + description: "Vous avez terminé une séance en Multi à 5 athlètes.", + category: 'social', + hidden: false, + }, }; // Nombre total de trophées dans le catalogue (utile pour les stats) @@ -216,6 +232,30 @@ async function checkAndUnlockAchievements(userId) { if (hasMaxFriend) tryUnlock('FRIENDSHIP_LEVEL_5'); } + // ── Lobby Multi (Section VII) ────────────────────────────────────────────── + if (!unlockedIds.has('FIRST_MULTI_SESSION') || !unlockedIds.has('MULTI_SQUAD_FULL')) { + // Require tardif : évite le cycle reward.controller ↔ workoutLobby.controller + // (celui-ci appelle déjà checkAndUnlockAchievements à la clôture du lobby). + const WorkoutLobby = require('../models/WorkoutLobby'); + + if (!unlockedIds.has('FIRST_MULTI_SESSION')) { + const hasCompletedMulti = await WorkoutLobby.exists({ + status: 'completed', + 'members.user': userId, + }); + if (hasCompletedMulti) tryUnlock('FIRST_MULTI_SESSION'); + } + + if (!unlockedIds.has('MULTI_SQUAD_FULL')) { + const hasFullSquad = await WorkoutLobby.exists({ + status: 'completed', + 'members.user': userId, + memberCount: 5, + }); + if (hasFullSquad) tryUnlock('MULTI_SQUAD_FULL'); + } + } + if (newlyUnlocked.length > 0) { user.markModified('achievements'); await user.save(); diff --git a/back/controllers/workoutLobby.controller.js b/back/controllers/workoutLobby.controller.js new file mode 100644 index 0000000..5b35836 --- /dev/null +++ b/back/controllers/workoutLobby.controller.js @@ -0,0 +1,384 @@ +'use strict'; + +const mongoose = require('mongoose'); +const WorkoutLobby = require('../models/WorkoutLobby'); +const User = require('../models/User'); +const Friendship = require('../models/Friendship'); +const { sendPushToUser } = require('../services/push.service'); +const { checkAndUnlockAchievements } = require('./reward.controller'); + +const { MAX_MEMBERS } = WorkoutLobby; +const MEMBER_PUBLIC_FIELDS = 'pseudo level rank equippedFrame'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function createError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +function isValidId(id) { + return mongoose.Types.ObjectId.isValid(id); +} + +// Bonus XP Multi (Section VII) : généreux à dessein — assembler un groupe de +// 5 est rare, ça doit vraiment valoir le coup (2 joueurs → 15%, 3 → 25%, +// 4 → 35%, 5 → 50%). Table plutôt que formule linéaire pour garder des +// paliers ronds et un gros saut au dernier membre. +const MULTI_BONUS_BY_COUNT = { 1: 0, 2: 0.15, 3: 0.25, 4: 0.35, 5: 0.50 }; +function computeMultiBonusPercent(memberCount) { + if (memberCount >= 5) return MULTI_BONUS_BY_COUNT[5]; + return MULTI_BONUS_BY_COUNT[memberCount] ?? 0; +} + +async function populateLobby(lobby) { + await lobby.populate('members.user', MEMBER_PUBLIC_FIELDS); + return lobby; +} + +/** + * Auto-progresse tout membre "bot" de test (voir simulateLobbyInvite dans + * debug.controller.js) vers `targetStatus` — miroir instantané de l'action de + * l'utilisateur réel, pour tester le flux complet du Lobby Multi en solo + * sans second compte/appareil. N'affecte jamais les membres humains. + */ +async function autoProgressBots(lobby, targetStatus) { + const memberIds = lobby.members.map((m) => m.user); + const bots = await User.find({ _id: { $in: memberIds }, isTestBot: true }).select('_id'); + if (bots.length === 0) return; + + const botIds = new Set(bots.map((b) => b._id.toString())); + lobby.members.forEach((m) => { + if (botIds.has(m.user.toString())) m.status = targetStatus; + }); +} + +function serializeLobby(lobby) { + const plain = lobby.toObject(); + return { + _id: plain._id, + creatorId: plain.creatorId, + status: plain.status, + creationDate: plain.creationDate, + memberCount: plain.memberCount, + xpBonusPercent: plain.xpBonusPercent, + members: plain.members.map((m) => ({ + user: m.user, + status: m.status, + })), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// createLobby POST /api/lobby/create +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Crée un salon de séance Multi au statut 'waiting', avec le créateur comme + * premier membre (statut 'waiting' — il doit lui aussi passer 'ready'). + */ +exports.createLobby = async (req, res, next) => { + try { + const myId = req.user.id; + + const lobby = await WorkoutLobby.create({ + creatorId: myId, + members: [{ user: myId, status: 'waiting' }], + memberCount: 1, + status: 'waiting', + }); + + await populateLobby(lobby); + + return res.status(201).json({ success: true, lobby: serializeLobby(lobby) }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// getLobby GET /api/lobby/:id +// ───────────────────────────────────────────────────────────────────────────── + +/** Consultation de l'état courant du lobby — pollée par le front (pas de websocket). */ +exports.getLobby = async (req, res, next) => { + try { + const { id } = req.params; + if (!isValidId(id)) return next(createError('id de lobby invalide.', 400)); + + const lobby = await WorkoutLobby.findById(id); + if (!lobby) return next(createError('Lobby introuvable.', 404)); + + const myId = req.user.id; + if (!lobby.members.some((m) => m.user.toString() === myId)) { + return next(createError('Vous ne faites pas partie de ce lobby.', 403)); + } + + await populateLobby(lobby); + return res.status(200).json({ success: true, lobby: serializeLobby(lobby) }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// inviteToLobby POST /api/lobby/:id/invite +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Envoie une VRAIE notification push à un ami pour l'inviter à rejoindre le + * lobby — ne l'ajoute pas comme membre (il rejoint lui-même via /join en + * ouvrant la notification, comme pour le bouton Secouer). + * + * Sécurités : seul un membre du lobby peut inviter ; seul un ami accepté peut + * être invité ; le lobby doit encore accepter des membres (waiting, < 5). + */ +exports.inviteToLobby = async (req, res, next) => { + try { + const myId = req.user.id; + const { id } = req.params; + const { friendId } = req.body; + + if (!isValidId(id)) return next(createError('id de lobby invalide.', 400)); + if (!friendId || !isValidId(friendId)) return next(createError('friendId manquant ou invalide.', 400)); + + const lobby = await WorkoutLobby.findById(id); + if (!lobby) return next(createError('Lobby introuvable.', 404)); + + if (!lobby.members.some((m) => m.user.toString() === myId)) { + return next(createError('Vous ne faites pas partie de ce lobby.', 403)); + } + if (lobby.status !== 'waiting') { + return next(createError("Ce lobby n'accepte plus de nouveaux membres.", 422)); + } + if (lobby.memberCount >= MAX_MEMBERS) { + return next(createError('Le lobby est déjà complet (5 max).', 422)); + } + + const isFriend = await Friendship.exists({ + $or: [ + { requester: myId, recipient: friendId, status: 'accepted' }, + { requester: friendId, recipient: myId, status: 'accepted' }, + ], + }); + if (!isFriend) return next(createError("Vous ne pouvez inviter qu'un ami accepté.", 403)); + + const [me, friend] = await Promise.all([ + User.findById(myId).select('pseudo'), + User.findById(friendId).select('pseudo'), + ]); + if (!friend) return next(createError('Utilisateur introuvable.', 404)); + + await sendPushToUser(friendId, { + title: 'Invitation Multi', + body: `${me?.pseudo ?? 'Un ami'} t'invite à une séance Multi !`, + data: { type: 'lobby_invite', lobbyId: lobby._id.toString(), fromPseudo: me?.pseudo ?? 'Un ami' }, + }); + + return res.status(200).json({ + success: true, + message: `Invitation envoyée à ${friend.pseudo}.`, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// joinLobby POST /api/lobby/:id/join +// ───────────────────────────────────────────────────────────────────────────── + +/** Rejoint un lobby existant (statut initial 'waiting') — idempotent. */ +exports.joinLobby = async (req, res, next) => { + try { + const myId = req.user.id; + const { id } = req.params; + if (!isValidId(id)) return next(createError('id de lobby invalide.', 400)); + + const lobby = await WorkoutLobby.findById(id); + if (!lobby) return next(createError('Lobby introuvable.', 404)); + + if (lobby.status !== 'waiting') { + return next(createError("Ce lobby n'accepte plus de nouveaux membres.", 422)); + } + + const alreadyMember = lobby.members.some((m) => m.user.toString() === myId); + if (!alreadyMember) { + if (lobby.memberCount >= MAX_MEMBERS) { + return next(createError('Le lobby est déjà complet (5 max).', 422)); + } + lobby.members.push({ user: myId, status: 'waiting' }); + lobby.memberCount = lobby.members.length; + await lobby.save(); + } + + await populateLobby(lobby); + return res.status(200).json({ success: true, lobby: serializeLobby(lobby) }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// readyLobby POST /api/lobby/:id/ready +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Passe le statut du membre appelant à 'ready' (le rejoint d'abord s'il n'est + * pas encore membre — évite un aller-retour join+ready côté front). Dès que + * TOUS les membres sont 'ready', le lobby passe 'active'. + */ +exports.readyLobby = async (req, res, next) => { + try { + const myId = req.user.id; + const { id } = req.params; + if (!isValidId(id)) return next(createError('id de lobby invalide.', 400)); + + const lobby = await WorkoutLobby.findById(id); + if (!lobby) return next(createError('Lobby introuvable.', 404)); + + if (lobby.status !== 'waiting') { + return next(createError(`Impossible de se déclarer prêt : le lobby est au statut "${lobby.status}".`, 422)); + } + + let member = lobby.members.find((m) => m.user.toString() === myId); + if (!member) { + if (lobby.memberCount >= MAX_MEMBERS) { + return next(createError('Le lobby est déjà complet (5 max).', 422)); + } + lobby.members.push({ user: myId, status: 'ready' }); + lobby.memberCount = lobby.members.length; + } else { + member.status = 'ready'; + } + + // Coéquipiers de test God Mode : ils se déclarent prêts en même temps que + // vous, pour pouvoir tester le passage à 'active' en solo. + await autoProgressBots(lobby, 'ready'); + + // Un lobby "Multi" à 1 seul membre n'a pas de sens : n'active qu'à partir + // de 2 membres, sinon "100% de 1 personne prête" activerait le lobby dès + // le créateur seul, avant même qu'un ami ait pu le rejoindre. + const allReady = lobby.memberCount >= 2 && lobby.members.every((m) => m.status === 'ready'); + if (allReady) { + lobby.status = 'active'; + } + + await lobby.save(); + await populateLobby(lobby); + return res.status(200).json({ success: true, lobby: serializeLobby(lobby) }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// unreadyLobby POST /api/lobby/:id/unready +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Repasse le statut du membre appelant à 'waiting' — pendant du bouton + * "Je ne suis plus prêt" côté front. Seulement possible tant que le lobby + * est encore 'waiting' (une fois 'active', tout le monde a déjà commencé, + * il n'y a plus rien à annuler). + */ +exports.unreadyLobby = async (req, res, next) => { + try { + const myId = req.user.id; + const { id } = req.params; + if (!isValidId(id)) return next(createError('id de lobby invalide.', 400)); + + const lobby = await WorkoutLobby.findById(id); + if (!lobby) return next(createError('Lobby introuvable.', 404)); + + if (lobby.status !== 'waiting') { + return next(createError(`Impossible d'annuler : le lobby est au statut "${lobby.status}".`, 422)); + } + + const member = lobby.members.find((m) => m.user.toString() === myId); + if (!member) { + return next(createError('Vous ne faites pas partie de ce lobby.', 403)); + } + + member.status = 'waiting'; + await lobby.save(); + await populateLobby(lobby); + return res.status(200).json({ success: true, lobby: serializeLobby(lobby) }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// finishLobby POST /api/lobby/:id/finish +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Passe le statut du membre appelant à 'finished'. Dès que TOUS les membres + * ont fini, le lobby passe 'completed', le bonus XP Multi est figé + * (`xpBonusPercent`), et les trophées sociaux Multi sont vérifiés pour + * chaque membre (FIRST_MULTI_SESSION, MULTI_SQUAD_FULL à 5 joueurs). + */ +exports.finishLobby = async (req, res, next) => { + try { + const myId = req.user.id; + const { id } = req.params; + if (!isValidId(id)) return next(createError('id de lobby invalide.', 400)); + + const lobby = await WorkoutLobby.findById(id); + if (!lobby) return next(createError('Lobby introuvable.', 404)); + + if (lobby.status !== 'active') { + return next(createError(`Impossible de terminer : le lobby est au statut "${lobby.status}".`, 422)); + } + + const member = lobby.members.find((m) => m.user.toString() === myId); + if (!member) { + return next(createError('Vous ne faites pas partie de ce lobby.', 403)); + } + + member.status = 'finished'; + + // Coéquipiers de test God Mode : ils terminent en même temps que vous. + await autoProgressBots(lobby, 'finished'); + + const allFinished = lobby.members.every((m) => m.status === 'finished'); + if (allFinished) { + lobby.status = 'completed'; + lobby.xpBonusPercent = computeMultiBonusPercent(lobby.memberCount); + } + + // Persiste AVANT de vérifier les trophées : checkAndUnlockAchievements + // interroge WorkoutLobby en base (status: 'completed') — s'il tournait + // avant ce save(), il verrait encore l'ancien statut 'active' et ne + // débloquerait jamais rien. + await lobby.save(); + + let newlyUnlockedByUser = {}; + if (allFinished) { + // Best-effort : les trophées ne doivent jamais faire échouer la clôture. + try { + const results = await Promise.all( + lobby.members.map((m) => checkAndUnlockAchievements(m.user.toString())), + ); + lobby.members.forEach((m, i) => { newlyUnlockedByUser[m.user.toString()] = results[i]; }); + } catch (_) { + // ignore — la clôture du lobby ne doit pas dépendre des trophées. + } + } + + await populateLobby(lobby); + + return res.status(200).json({ + success: true, + lobby: serializeLobby(lobby), + completed: allFinished, + newlyUnlocked: allFinished ? (newlyUnlockedByUser[myId] ?? []) : [], + }); + } catch (err) { + next(err); + } +}; + +module.exports.computeMultiBonusPercent = computeMultiBonusPercent; diff --git a/back/data/localTrophyCatalog.js b/back/data/localTrophyCatalog.js index 1b7bbb4..6904b9f 100644 --- a/back/data/localTrophyCatalog.js +++ b/back/data/localTrophyCatalog.js @@ -18,16 +18,16 @@ const T = (id, category, icon, label, condition, epicDesc, color, gradientColors const LOCAL_TROPHY_LIST = [ // ── HÉRITAGE (6) ── T('ignition', 'heritage', 'flame', 'Ignition', '1ère séance', - "La flamme s'allume. Votre premier pas dans l'arène — et rien ne sera jamais plus pareil.", + "La flamme s'allume. Votre premier pas dans l'arène - et rien ne sera jamais plus pareil.", '#FE7439', ['#FF9A5C', '#FE7439', '#C44A10'], 'bronze'), T('promise', 'heritage', 'medal', 'Promesse', 'Niveau 10', - "Le novice est mort. Vous avez prouvé que vous êtes là pour durer — la promesse est tenue.", + "Le novice est mort. Vous avez prouvé que vous êtes là pour durer - la promesse est tenue.", '#FFD700', ['#FFE566', '#FFD700', '#B8860B'], 'gold'), T('apprenti', 'heritage', 'school', 'Apprenti', 'Niveau 25', - "Les bases sont posées. Chaque set vous a sculpté — vous n'êtes plus un débutant, vous êtes un apprenti.", + "Les bases sont posées. Chaque set vous a sculpté - vous n'êtes plus un débutant, vous êtes un apprenti.", '#34D399', ['#6EE7B7', '#34D399', '#059669'], 'silver'), T('centurion', 'heritage', 'trophy', 'Centurion', '50 séances', - "Votre volonté est d'acier. 50 combats menés avec honneur — les légions vous saluent.", + "Votre volonté est d'acier. 50 combats menés avec honneur - les légions vous saluent.", '#6E6AF0', ['#9B97FF', '#6E6AF0', '#3D3A9E'], 'platinum'), T('veteran', 'heritage', 'shield-checkmark', 'Vétéran', 'Niveau 75', "Trois quarts du chemin vers le sommet. Vous portez les cicatrices de centaines de batailles.", @@ -61,13 +61,13 @@ const LOCAL_TROPHY_LIST = [ // ── EXPLORATION (6) ── T('polyvalent', 'exploration', 'grid', 'Polyvalent', '5 groupes musculaires', - "Pecs, dos, jambes, épaules, bras — vous ne laissez aucun muscle au repos.", + "Pecs, dos, jambes, épaules, bras - vous ne laissez aucun muscle au repos.", '#22C55E', ['#4ADE80', '#22C55E', '#15803D'], 'silver'), T('marathonien', 'exploration', 'time', 'Marathonien', 'Séance ≥ 60 min', "Une heure dans l'arène. Quand les autres partaient après 30 minutes, vous étiez encore là.", '#0EA5E9', ['#38BDF8', '#0EA5E9', '#0369A1'], 'bronze'), T('demi_legende', 'exploration', 'rocket', 'Demi-Légende', 'Niveau 50', - "La moitié du chemin vers le sommet. Peu y arrivent — vous y êtes.", + "La moitié du chemin vers le sommet. Peu y arrivent - vous y êtes.", '#3B82F6', ['#60A5FA', '#3B82F6', '#1D4ED8'], 'gold'), T('xp_millionaire', 'exploration', 'infinite', 'XP Millionnaire', '1 000 000 XP cumulés', "Un million de points d'expérience. Une vie entière de sueur, de fer et de détermination.", @@ -102,12 +102,12 @@ const LOCAL_TROPHY_LIST = [ "Minuit passé. Les loups chassent quand le troupeau dort.", '#4338CA', ['#6366F1', '#4338CA', '#1E1B4B'], 'silver', true), T('athly_god', 'secret', 'planet', 'ATHLY GOD', 'Niveau 200', - "Le sommet absolu. Vous n'êtes plus un athlète — vous êtes une légende vivante. Le trône vous appartient.", + "Le sommet absolu. Vous n'êtes plus un athlète - vous êtes une légende vivante. Le trône vous appartient.", '#FFD700', ['#FDE68A', '#FFD700', '#92400E'], 'diamond', true), // ── CORPS (5) ── T('corps_bronze', 'corps', 'fitness', 'Initié Poids Corps', '50 sets complétés', - "Cinquante séries avec votre propre corps. Pas de barres, pas de charges — juste vous contre la gravité.", + "Cinquante séries avec votre propre corps. Pas de barres, pas de charges - juste vous contre la gravité.", '#CD7F32', ['#E8A060', '#CD7F32', '#6B3A1A'], 'bronze'), T('corps_silver', 'corps', 'walk', 'Guerrier Poids Corps', '150 sets complétés', "Cent cinquante séries. Votre poids corporel est devenu votre outil de sculpture le plus précis.", @@ -124,7 +124,7 @@ const LOCAL_TROPHY_LIST = [ // ── RÉGULARITÉ (5) ── T('reg_3m', 'regularite', 'calendar-outline', 'Constance 3 Mois', 'Séances sur 3 mois', - "Trois mois d'entraînement. Pas une mode — une véritable habitude.", + "Trois mois d'entraînement. Pas une mode - une véritable habitude.", '#10B981', ['#34D399', '#10B981', '#065F46'], 'bronze'), T('reg_6m', 'regularite', 'time-outline', 'Constance 6 Mois', 'Séances sur 6 mois', "Six mois. La moitié d'une année dédiée au progrès.", @@ -144,17 +144,17 @@ const LOCAL_TROPHY_LIST = [ "Les grandes épopées ne se vivent pas seules. Votre premier compagnon d'armes vous attend.", '#EC4899', ['#F472B6', '#EC4899', '#9D174D'], 'bronze'), T('mentor', 'social', 'people-circle', 'Mentor', 'Inspirer 5 amis', - "Vous avez allumé la flamme chez cinq autres — vous êtes plus qu'un athlète, vous êtes un mentor.", + "Vous avez allumé la flamme chez cinq autres - vous êtes plus qu'un athlète, vous êtes un mentor.", '#8B5CF6', ['#A78BFA', '#8B5CF6', '#4C1D95'], 'gold'), // ── SPÉCIAL (1) ── T('athly_birthday', 'special', 'gift', 'Anniversaire Athly', 'Séance le 13 mai', - "Le jour où Athly est né, vous étiez là — à suer, à pousser, à vous dépasser.", + "Le jour où Athly est né, vous étiez là - à suer, à pousser, à vous dépasser.", '#FE7439', ['#FF9A5C', '#FE7439', '#C44A10'], 'gold'), // ── ULTIME (1) ── T('souverain_absolu', 'ultime', 'infinite', 'Souverain Absolu', 'Tous les trophées débloqués', - "Il n'existe pas de plus grand accomplissement. Vous avez tout conquis, tout maîtrisé, tout surpassé. L'empire d'Athly vous appartient — et l'univers entier s'incline devant vous.", + "Il n'existe pas de plus grand accomplissement. Vous avez tout conquis, tout maîtrisé, tout surpassé. L'empire d'Athly vous appartient - et l'univers entier s'incline devant vous.", '#FFD700', ['#FFFACD', '#FFD700', '#FF8C00', '#C44A10'], 'diamond'), ]; diff --git a/back/models/User.js b/back/models/User.js index 719d699..dba79ee 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -56,6 +56,20 @@ const UserSchema = new mongoose.Schema( // backfillés lazily — voir user.service.js → getUserProfile) : l'index // unique ci-dessous n'agit que sur les documents qui en ont déjà un. discriminator: { type: String, match: /^\d{4}$/ }, + + // ── Google OAuth (Section VIII) ─────────────────────────────────────────── + // `sub` (identifiant unique Google) du compte lié, si connecté via + // "Se connecter avec Google" — voir auth.service.js → googleLogin. + // null pour tous les comptes email/mot de passe classiques. + googleId: { type: String, unique: true, sparse: true }, + + // ── Compte de test God Mode (Section VII) ───────────────────────────────── + // true uniquement pour les coéquipiers factices créés par + // simulateLobbyInvite (debug.controller.js) — leur statut de Lobby Multi + // est auto-progressé en miroir du vôtre (voir workoutLobby.controller.js), + // pour tester le flux complet en solo sans second appareil. + isTestBot: { type: Boolean, default: false }, + email: { type: String, required: [true, "L'e-mail est obligatoire"], diff --git a/back/models/WorkoutLobby.js b/back/models/WorkoutLobby.js new file mode 100644 index 0000000..39104aa --- /dev/null +++ b/back/models/WorkoutLobby.js @@ -0,0 +1,66 @@ +const mongoose = require("mongoose"); + +// ----------------------------------------------------- +// Modèle "WorkoutLobby" (Section VII — Lobby Multi) +// ----------------------------------------------------- +// Salon de séance synchronisée entre 2 à 5 athlètes. Chacun gère ses propres +// séries/poids côté client (résilience réseau) — le lobby ne sert qu'à +// synchroniser 3 moments clés : la mise en route (waiting → active dès que +// TOUS les membres sont 'ready'), et la clôture (active → completed dès que +// TOUS les membres sont 'finished'), moment où le bonus XP Multi est calculé +// (voir workoutLobby.controller.js). +// +// `memberCount` est dénormalisé (recalculé à chaque ajout de membre) pour +// permettre une requête simple "lobby complet à 5" côté trophées, sans agréger +// la taille du tableau `members` à chaque fois. +// ----------------------------------------------------- + +const MAX_MEMBERS = 5; + +const LobbyMemberSchema = new mongoose.Schema( + { + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + status: { + type: String, + enum: ["waiting", "ready", "finished"], + default: "waiting", + }, + }, + { _id: false } +); + +const WorkoutLobbySchema = new mongoose.Schema( + { + creatorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + members: { + type: [LobbyMemberSchema], + default: [], + validate: { + validator: (arr) => arr.length <= MAX_MEMBERS, + message: `Un lobby ne peut pas dépasser ${MAX_MEMBERS} membres.`, + }, + }, + memberCount: { type: Number, default: 0 }, + status: { + type: String, + enum: ["waiting", "active", "completed"], + default: "waiting", + }, + creationDate: { type: Date, default: Date.now }, + + // Bonus XP Multi appliqué à la clôture (voir computeMultiBonusPercent) — + // figé au moment où le dernier membre finit, pour un affichage cohérent + // même si (en théorie) memberCount changeait après coup. + xpBonusPercent: { type: Number, default: 0 }, + }, + { timestamps: true } +); + +WorkoutLobbySchema.index({ "members.user": 1, status: 1 }); + +module.exports = mongoose.model("WorkoutLobby", WorkoutLobbySchema); +module.exports.MAX_MEMBERS = MAX_MEMBERS; diff --git a/back/package-lock.json b/back/package-lock.json index 107c467..6f2332f 100644 --- a/back/package-lock.json +++ b/back/package-lock.json @@ -16,6 +16,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.2", "express-validator": "^7.2.1", + "google-auth-library": "^10.9.0", "helmet": "^8.0.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", @@ -1950,7 +1951,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -2301,6 +2301,26 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -2346,6 +2366,15 @@ "node": ">= 18" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2954,6 +2983,15 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3573,6 +3611,12 @@ "node": ">= 8.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3618,6 +3662,29 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3815,6 +3882,18 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -3882,6 +3961,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz", + "integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4056,6 +4149,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4168,7 +4301,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -5153,6 +5285,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6010,6 +6151,44 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -7752,6 +7931,15 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/back/package.json b/back/package.json index 8bf0e65..6421491 100644 --- a/back/package.json +++ b/back/package.json @@ -27,6 +27,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.2", "express-validator": "^7.2.1", + "google-auth-library": "^10.9.0", "helmet": "^8.0.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", diff --git a/back/routes/auth.routes.js b/back/routes/auth.routes.js index 6deebfc..b485f56 100644 --- a/back/routes/auth.routes.js +++ b/back/routes/auth.routes.js @@ -18,6 +18,12 @@ router.post("/login", ctrl.loginUser ); +// POST /api/auth/google — connexion en un clic (idToken vérifié côté serveur) +router.post("/google", + validate(schemas.googleLogin), + ctrl.googleLogin +); + // POST /api/auth/verify-email router.post("/verify-email", validate(schemas.verifyEmail), diff --git a/back/routes/debug.routes.js b/back/routes/debug.routes.js index e21f47e..9ee6702 100644 --- a/back/routes/debug.routes.js +++ b/back/routes/debug.routes.js @@ -29,4 +29,7 @@ router.post('/godmode/simulate-shake-self', debug.simulateShakeSelf); // ── God Mode : Vague 2 (tag Discord, ajout d'ami) ──────────────────────────── router.post('/godmode/simulate-searchable-friend', debug.simulateSearchableFriend); +// ── God Mode : Vague 3 (Lobby Multi) ───────────────────────────────────────── +router.post('/godmode/simulate-lobby-invite', debug.simulateLobbyInvite); + module.exports = router; diff --git a/back/routes/workoutLobby.routes.js b/back/routes/workoutLobby.routes.js new file mode 100644 index 0000000..b9ae2a2 --- /dev/null +++ b/back/routes/workoutLobby.routes.js @@ -0,0 +1,20 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth.middleware'); +const validate = require('../middleware/validate.middleware'); +const lobby = require('../controllers/workoutLobby.controller'); +const { inviteToLobby } = require('../validators/workoutLobby.validator'); + +router.use(auth); + +router.post('/create', lobby.createLobby); +router.get('/:id', lobby.getLobby); +router.post('/:id/invite', validate(inviteToLobby), lobby.inviteToLobby); +router.post('/:id/join', lobby.joinLobby); +router.post('/:id/ready', lobby.readyLobby); +router.post('/:id/unready', lobby.unreadyLobby); +router.post('/:id/finish', lobby.finishLobby); + +module.exports = router; diff --git a/back/services/auth.service.js b/back/services/auth.service.js index 400bb7f..f07d72c 100644 --- a/back/services/auth.service.js +++ b/back/services/auth.service.js @@ -6,6 +6,7 @@ const jwt = require("jsonwebtoken"); const config = require("../config/env"); const emailService = require("./email.service"); const { addItemAtomic } = require("./inventory.service"); +const { containsProfanity } = require("../utils/profanityFilter"); const MAX_OTP_ATTEMPTS = 5; const CODE_TTL_VERIFY = 10 * 60 * 1000; // 10 min @@ -84,6 +85,10 @@ class AuthService { * FIRST_REFERRAL, et les deux sont liés en amis "accepted" d'office. */ async register(pseudo, email, password, referralCode = null) { + if (containsProfanity(pseudo)) { + throw httpError("Ce pseudo n'est pas autorisé. Choisis-en un autre.", 422, "PSEUDO_NOT_ALLOWED"); + } + const existing = await User.findOne({ email }); if (existing) throw httpError("Un utilisateur avec cet email existe déjà.", 409, "EMAIL_TAKEN"); @@ -195,6 +200,82 @@ class AuthService { }; } + // ── Connexion Google OAuth (Section VIII) ───────────────────────────────── + /** + * Connexion en un clic via Google : le client (app mobile) obtient un + * `idToken` via expo-auth-session / Google Sign-In, l'envoie ici pour + * vérification côté serveur (jamais confiance en un payload décodé côté + * client, qui pourrait être falsifié). + * + * Compte trouvé par `googleId` → connexion directe. + * Sinon par `email` (compte déjà créé au mot de passe) → on lie googleId à + * ce compte existant plutôt que d'en créer un doublon. + * Sinon → création d'un nouveau compte (email déjà vérifié par Google, + * mot de passe aléatoire jamais utilisable pour se connecter autrement). + */ + async googleLogin(idToken) { + if (!config.googleClientId) { + throw httpError("Connexion Google non configurée sur ce serveur.", 501, "GOOGLE_OAUTH_NOT_CONFIGURED"); + } + if (!idToken) { + throw httpError("idToken manquant.", 400, "GOOGLE_TOKEN_MISSING"); + } + + const { OAuth2Client } = require("google-auth-library"); + const client = new OAuth2Client(config.googleClientId); + + let payload; + try { + const ticket = await client.verifyIdToken({ idToken, audience: config.googleClientId }); + payload = ticket.getPayload(); + } catch (_err) { + throw httpError("Token Google invalide.", 401, "GOOGLE_TOKEN_INVALID"); + } + + if (!payload?.sub || !payload?.email) { + throw httpError("Token Google invalide.", 401, "GOOGLE_TOKEN_INVALID"); + } + + let user = await User.findOne({ googleId: payload.sub }); + + if (!user) { + user = await User.findOne({ email: payload.email.toLowerCase() }); + if (user) { + user.googleId = payload.sub; + if (!user.isVerified) user.isVerified = true; // Google a déjà vérifié cet email + await user.save(); + } + } + + if (!user) { + const pseudo = (payload.name || payload.email.split("@")[0]).slice(0, 50); + const randomPassword = await bcrypt.hash(crypto.randomBytes(24).toString("hex"), BCRYPT_ROUNDS); + + user = await User.create({ + pseudo, + name: pseudo, + email: payload.email.toLowerCase(), + password: randomPassword, + isVerified: true, + googleId: payload.sub, + referralCode: await uniqueReferralCode(), + discriminator: await uniqueDiscriminator(pseudo), + }); + } + + const token = makeToken(user._id); + return { + token, + user: { + id: user._id, + pseudo: user.pseudo || user.name, + discriminator: user.discriminator, + email: user.email, + level: user.level, + }, + }; + } + // ── Vérification email ───────────────────────────────────────────────────── async verifyEmail(email, code) { const user = await User.findOne({ email }); diff --git a/back/services/email.service.js b/back/services/email.service.js index 6e8200a..785601c 100644 --- a/back/services/email.service.js +++ b/back/services/email.service.js @@ -75,7 +75,7 @@ function baseTemplate(title, bodyHtml) {

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.
- © 2026 Athly — Tous droits réservés. + © 2026 Athly - Tous droits réservés.

diff --git a/back/tests/debug.test.js b/back/tests/debug.test.js index 7889acb..80a5d3d 100644 --- a/back/tests/debug.test.js +++ b/back/tests/debug.test.js @@ -482,4 +482,51 @@ describe('God Mode — outils de test Vague 1', () => { expect(res.statusCode).toBe(401); }); }); + + describe('POST /api/debug/godmode/simulate-lobby-invite', () => { + it('✅ Crée un lobby avec un coéquipier isTestBot et renvoie lobbyId', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-lobby-invite') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(201); + expect(res.body.lobbyId).toBeTruthy(); + expect(res.body.fromPseudo).toBe('CoequipierBot'); + + const bot = await User.findOne({ pseudo: 'CoequipierBot' }); + expect(bot.isTestBot).toBe(true); + }); + + it('✅ pushed: false si aucun token push enregistré (jamais d\'erreur)', async () => { + const res = await request(app) + .post('/api/debug/godmode/simulate-lobby-invite') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(201); + expect(res.body.pushed).toBe(false); + }); + + it('🔁 Idempotent : rejouer supprime le bot/lobby précédents avant recréation', async () => { + const first = await request(app) + .post('/api/debug/godmode/simulate-lobby-invite') + .set('Authorization', `Bearer ${alice.token}`); + const second = await request(app) + .post('/api/debug/godmode/simulate-lobby-invite') + .set('Authorization', `Bearer ${alice.token}`); + + expect(second.body.lobbyId).not.toBe(first.body.lobbyId); + + const botCount = await User.countDocuments({ pseudo: 'CoequipierBot' }); + expect(botCount).toBe(1); + + const WorkoutLobby = require('../models/WorkoutLobby'); + const oldLobby = await WorkoutLobby.findById(first.body.lobbyId); + expect(oldLobby).toBeNull(); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/simulate-lobby-invite'); + expect(res.statusCode).toBe(401); + }); + }); }); diff --git a/back/tests/googleAuth.test.js b/back/tests/googleAuth.test.js new file mode 100644 index 0000000..b095838 --- /dev/null +++ b/back/tests/googleAuth.test.js @@ -0,0 +1,110 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const config = require('../config/env'); + +// google-auth-library fait un vrai appel réseau pour vérifier le token — on +// le mocke pour tester notre logique métier (find-or-create) sans dépendre +// des serveurs Google ni de vraies credentials dans les tests. +const mockVerifyIdToken = jest.fn(); +jest.mock('google-auth-library', () => ({ + OAuth2Client: jest.fn().mockImplementation(() => ({ + verifyIdToken: mockVerifyIdToken, + })), +})); + +const app = require('../app'); +const User = require('../models/User'); + +describe('POST /api/auth/google — connexion Google OAuth (Section VIII)', () => { + let originalClientId; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + originalClientId = config.googleClientId; + config.googleClientId = 'test-client-id.apps.googleusercontent.com'; + }); + + afterAll(async () => { + config.googleClientId = originalClientId; + await User.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + mockVerifyIdToken.mockReset(); + }); + + it('✅ Crée un nouveau compte au premier login Google', async () => { + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => ({ sub: 'google-sub-1', email: 'nouveau@gmail.com', name: 'Nouvel Athlète' }), + }); + + const res = await request(app) + .post('/api/auth/google') + .send({ idToken: 'fake-valid-token' }); + + expect(res.statusCode).toBe(200); + expect(res.body.token).toBeTruthy(); + expect(res.body.user.email).toBe('nouveau@gmail.com'); + + const user = await User.findOne({ email: 'nouveau@gmail.com' }); + expect(user.googleId).toBe('google-sub-1'); + expect(user.isVerified).toBe(true); + expect(user.discriminator).toMatch(/^\d{4}$/); + }); + + it('✅ Reconnecte directement un compte déjà lié (même googleId)', async () => { + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => ({ sub: 'google-sub-2', email: 'existant@gmail.com', name: 'Existant' }), + }); + const first = await request(app).post('/api/auth/google').send({ idToken: 't1' }); + const firstUserId = first.body.user.id; + + const second = await request(app).post('/api/auth/google').send({ idToken: 't2' }); + + expect(second.statusCode).toBe(200); + expect(second.body.user.id).toBe(firstUserId); + expect(await User.countDocuments({ email: 'existant@gmail.com' })).toBe(1); + }); + + it('✅ Lie googleId à un compte email/mot de passe existant (même email)', async () => { + await request(app).post('/api/auth/register').send({ + pseudo: 'DejaLa', email: 'deja.la@gmail.com', password: 'Password123!', + }); + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => ({ sub: 'google-sub-3', email: 'deja.la@gmail.com', name: 'Déjà Là' }), + }); + + const res = await request(app).post('/api/auth/google').send({ idToken: 't3' }); + + expect(res.statusCode).toBe(200); + const user = await User.findOne({ email: 'deja.la@gmail.com' }); + expect(user.googleId).toBe('google-sub-3'); + expect(user.isVerified).toBe(true); // vérifié automatiquement même si le compte email ne l'était pas + }); + + it('❌ 401 si le token Google est invalide', async () => { + mockVerifyIdToken.mockRejectedValue(new Error('Invalid token signature')); + + const res = await request(app).post('/api/auth/google').send({ idToken: 'invalid' }); + expect(res.statusCode).toBe(401); + }); + + it('❌ 400 si idToken manquant', async () => { + const res = await request(app).post('/api/auth/google').send({}); + expect(res.statusCode).toBe(400); + }); + + it("❌ 501 si Google OAuth n'est pas configuré (pas de GOOGLE_CLIENT_ID)", async () => { + config.googleClientId = null; + const res = await request(app).post('/api/auth/google').send({ idToken: 'whatever' }); + expect(res.statusCode).toBe(501); + config.googleClientId = 'test-client-id.apps.googleusercontent.com'; + }); +}); diff --git a/back/tests/profanityFilter.test.js b/back/tests/profanityFilter.test.js new file mode 100644 index 0000000..3f88533 --- /dev/null +++ b/back/tests/profanityFilter.test.js @@ -0,0 +1,68 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const { containsProfanity } = require('../utils/profanityFilter'); + +describe('Filtre anti-injures — Section VIII', () => { + describe('containsProfanity (unitaire)', () => { + it('✅ Détecte un mot banni exact', () => { + expect(containsProfanity('connard')).toBe(true); + }); + + it('✅ Détecte un mot banni intégré dans un pseudo', () => { + expect(containsProfanity('SuperConnard69')).toBe(true); + }); + + it('✅ Insensible à la casse et aux accents', () => { + expect(containsProfanity('ENCULÉ')).toBe(true); + expect(containsProfanity('encule')).toBe(true); + }); + + it('✅ Détecte un contournement leet-speak simple', () => { + expect(containsProfanity('put4in')).toBe(true); + }); + + it("❌ N'a pas de faux-positif sur un pseudo légitime", () => { + expect(containsProfanity('AliceFit')).toBe(false); + expect(containsProfanity('Classe2024')).toBe(false); // contient "ass" mais pas un mot banni isolé... voir note + }); + }); + + describe("POST /api/auth/register — rejet d'un pseudo vulgaire", () => { + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + }); + + it('❌ 422 si le pseudo est vulgaire', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ pseudo: 'ConnardDu92', email: 'test1@athly.fr', password: 'Password123!' }); + + expect(res.statusCode).toBe(422); + const user = await User.findOne({ email: 'test1@athly.fr' }); + expect(user).toBeNull(); + }); + + it('✅ 201 si le pseudo est propre', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ pseudo: 'AliceFit', email: 'test2@athly.fr', password: 'Password123!' }); + + expect(res.statusCode).toBe(201); + }); + }); +}); diff --git a/back/tests/workoutLobby.test.js b/back/tests/workoutLobby.test.js new file mode 100644 index 0000000..9652abc --- /dev/null +++ b/back/tests/workoutLobby.test.js @@ -0,0 +1,428 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const Friendship = require('../models/Friendship'); +const WorkoutLobby = require('../models/WorkoutLobby'); + +async function createAndLoginUser(pseudo, email) { + await request(app).post('/api/auth/register').send({ pseudo, email, password: 'Password123!' }); + await User.updateOne({ email }, { isVerified: true }); + const loginRes = await request(app).post('/api/auth/login').send({ email, password: 'Password123!' }); + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +async function makeFriends(a, b) { + return Friendship.create({ requester: a, recipient: b, status: 'accepted' }); +} + +describe('Lobby Multi — Section VII', () => { + let alice, bob, carol, dave; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + await WorkoutLobby.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Friendship.deleteMany({}); + await WorkoutLobby.deleteMany({}); + alice = await createAndLoginUser('AliceMulti', 'alice.multi@athly.fr'); + bob = await createAndLoginUser('BobMulti', 'bob.multi@athly.fr'); + carol = await createAndLoginUser('CarolMulti', 'carol.multi@athly.fr'); + dave = await createAndLoginUser('DaveMulti', 'dave.multi@athly.fr'); + }); + + describe('POST /api/lobby/create', () => { + it('✅ Crée un lobby waiting avec le créateur comme premier membre', async () => { + const res = await request(app) + .post('/api/lobby/create') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(201); + expect(res.body.lobby.status).toBe('waiting'); + expect(res.body.lobby.memberCount).toBe(1); + expect(res.body.lobby.members[0].user._id).toBe(alice.userId); + expect(res.body.lobby.members[0].status).toBe('waiting'); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/lobby/create'); + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/lobby/:id/invite', () => { + let lobbyId; + beforeEach(async () => { + const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + lobbyId = res.body.lobby._id; + }); + + it('✅ Envoie une invitation à un ami accepté (best-effort push)', async () => { + await makeFriends(alice.userId, bob.userId); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/invite`) + .set('Authorization', `Bearer ${alice.token}`) + .send({ friendId: bob.userId }); + + expect(res.statusCode).toBe(200); + expect(res.body.message).toMatch(/BobMulti/); + }); + + it("❌ 403 si la cible n'est pas un ami accepté", async () => { + const res = await request(app) + .post(`/api/lobby/${lobbyId}/invite`) + .set('Authorization', `Bearer ${alice.token}`) + .send({ friendId: bob.userId }); + + expect(res.statusCode).toBe(403); + }); + + it("❌ 403 si l'appelant ne fait pas partie du lobby", async () => { + await makeFriends(bob.userId, carol.userId); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/invite`) + .set('Authorization', `Bearer ${bob.token}`) + .send({ friendId: carol.userId }); + + expect(res.statusCode).toBe(403); + }); + + it('❌ 400 sans friendId', async () => { + const res = await request(app) + .post(`/api/lobby/${lobbyId}/invite`) + .set('Authorization', `Bearer ${alice.token}`) + .send({}); + expect(res.statusCode).toBe(400); + }); + }); + + describe('POST /api/lobby/:id/join', () => { + let lobbyId; + beforeEach(async () => { + const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + lobbyId = res.body.lobby._id; + }); + + it('✅ Un autre utilisateur peut rejoindre le lobby', async () => { + const res = await request(app) + .post(`/api/lobby/${lobbyId}/join`) + .set('Authorization', `Bearer ${bob.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.lobby.memberCount).toBe(2); + expect(res.body.lobby.members.map((m) => m.user._id)).toContain(bob.userId); + }); + + it('✅ Idempotent : rejoindre deux fois ne duplique pas le membre', async () => { + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${bob.token}`); + const res = await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${bob.token}`); + + expect(res.body.lobby.memberCount).toBe(2); + }); + + it('❌ 422 si le lobby est plein (5 membres)', async () => { + const extraTokens = []; + for (let i = 0; i < 3; i++) { + const u = await createAndLoginUser(`Filler${i}`, `filler${i}@athly.fr`); + extraTokens.push(u.token); + } + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${bob.token}`); + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${carol.token}`); + for (const t of extraTokens) { + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${t}`); + } + // 5 membres déjà (Alice + Bob + Carol + 2 fillers) — Dave ne peut plus entrer + const res = await request(app) + .post(`/api/lobby/${lobbyId}/join`) + .set('Authorization', `Bearer ${dave.token}`); + + expect(res.statusCode).toBe(422); + }); + + it('❌ 422 si le lobby a déjà démarré (active)', async () => { + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${bob.token}`); + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${alice.token}`); + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${bob.token}`); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/join`) + .set('Authorization', `Bearer ${carol.token}`); + expect(res.statusCode).toBe(422); + }); + }); + + describe('POST /api/lobby/:id/ready — passage waiting → active', () => { + let lobbyId; + beforeEach(async () => { + const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + lobbyId = res.body.lobby._id; + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${bob.token}`); + }); + + it("✅ Le lobby reste 'waiting' tant que tous ne sont pas prêts", async () => { + const res = await request(app) + .post(`/api/lobby/${lobbyId}/ready`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.lobby.status).toBe('waiting'); + expect(res.body.lobby.members.find((m) => m.user._id === alice.userId).status).toBe('ready'); + }); + + it("✅ Passe 'active' dès que 100% des membres sont ready", async () => { + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${alice.token}`); + const res = await request(app) + .post(`/api/lobby/${lobbyId}/ready`) + .set('Authorization', `Bearer ${bob.token}`); + + expect(res.body.lobby.status).toBe('active'); + }); + + it("✅ Ne s'active PAS si le créateur seul est prêt (1 membre insuffisant)", async () => { + const soloRes = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${carol.token}`); + const soloLobbyId = soloRes.body.lobby._id; + + const res = await request(app) + .post(`/api/lobby/${soloLobbyId}/ready`) + .set('Authorization', `Bearer ${carol.token}`); + + expect(res.body.lobby.status).toBe('waiting'); + }); + + it("✅ Rejoint automatiquement le lobby si pas encore membre", async () => { + const res = await request(app) + .post(`/api/lobby/${lobbyId}/ready`) + .set('Authorization', `Bearer ${carol.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.lobby.memberCount).toBe(3); + expect(res.body.lobby.members.find((m) => m.user._id === carol.userId).status).toBe('ready'); + }); + + it('❌ 422 si le lobby est déjà actif', async () => { + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${alice.token}`); + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${bob.token}`); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/ready`) + .set('Authorization', `Bearer ${carol.token}`); + expect(res.statusCode).toBe(422); + }); + }); + + describe('POST /api/lobby/:id/unready — annule son statut ready', () => { + let lobbyId; + beforeEach(async () => { + const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + lobbyId = res.body.lobby._id; + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${bob.token}`); + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${alice.token}`); + }); + + it("✅ Repasse le membre à 'waiting'", async () => { + const res = await request(app) + .post(`/api/lobby/${lobbyId}/unready`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.lobby.members.find((m) => m.user._id === alice.userId).status).toBe('waiting'); + }); + + it("❌ 422 si le lobby n'est plus 'waiting'", async () => { + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${bob.token}`); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/unready`) + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(422); + }); + + it("❌ 403 si l'appelant ne fait pas partie du lobby", async () => { + const res = await request(app) + .post(`/api/lobby/${lobbyId}/unready`) + .set('Authorization', `Bearer ${carol.token}`); + expect(res.statusCode).toBe(403); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post(`/api/lobby/${lobbyId}/unready`); + expect(res.statusCode).toBe(401); + }); + }); + + describe('God Mode — coéquipiers isTestBot auto-progressés', () => { + it("✅ Le lobby passe 'active' dès que le seul vrai joueur est ready (bot en miroir)", async () => { + const bot = await createAndLoginUser('BotMulti', 'bot.multi@athly.fr'); + await User.updateOne({ _id: bot.userId }, { isTestBot: true }); + + const createRes = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + const lobbyId = createRes.body.lobby._id; + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${bot.token}`); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/ready`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.lobby.status).toBe('active'); + expect(res.body.lobby.members.find((m) => m.user._id === bot.userId).status).toBe('ready'); + }); + + it("✅ Le lobby passe 'completed' dès que le seul vrai joueur a fini (bot en miroir)", async () => { + const bot = await createAndLoginUser('BotMulti2', 'bot2.multi@athly.fr'); + await User.updateOne({ _id: bot.userId }, { isTestBot: true }); + + const createRes = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + const lobbyId = createRes.body.lobby._id; + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${bot.token}`); + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${alice.token}`); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/finish`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.completed).toBe(true); + expect(res.body.lobby.status).toBe('completed'); + expect(res.body.lobby.xpBonusPercent).toBeCloseTo(0.15); + }); + }); + + describe('POST /api/lobby/:id/finish — passage active → completed + bonus XP', () => { + // Tous les membres rejoignent D'ABORD (pour que le lobby connaisse déjà + // son effectif complet), puis tout le monde se déclare prêt — sinon le + // premier "ready" activerait le lobby avant que les autres n'aient pu + // rejoindre (voir le garde-fou memberCount >= 2 dans readyLobby). + async function createActiveLobby(members) { + const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${members[0].token}`); + const lobbyId = res.body.lobby._id; + for (const m of members.slice(1)) { + await request(app).post(`/api/lobby/${lobbyId}/join`).set('Authorization', `Bearer ${m.token}`); + } + for (const m of members) { + await request(app).post(`/api/lobby/${lobbyId}/ready`).set('Authorization', `Bearer ${m.token}`); + } + return lobbyId; + } + + it("✅ Reste 'active' tant que tout le monde n'a pas fini", async () => { + const lobbyId = await createActiveLobby([alice, bob]); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/finish`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.completed).toBe(false); + expect(res.body.lobby.status).toBe('active'); + }); + + it("✅ Passe 'completed' avec le bon bonus XP quand tous ont fini (2 joueurs → 15%)", async () => { + const lobbyId = await createActiveLobby([alice, bob]); + + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + const res = await request(app) + .post(`/api/lobby/${lobbyId}/finish`) + .set('Authorization', `Bearer ${bob.token}`); + + expect(res.body.completed).toBe(true); + expect(res.body.lobby.status).toBe('completed'); + expect(res.body.lobby.xpBonusPercent).toBeCloseTo(0.15); + }); + + it('✅ Bonus XP à 5 joueurs plafonné à 50%', async () => { + const eve = await createAndLoginUser('EveMulti', 'eve.multi@athly.fr'); + const lobbyId = await createActiveLobby([alice, bob, carol, dave, eve]); + + let res; + for (const m of [alice, bob, carol, dave, eve]) { + res = await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${m.token}`); + } + + expect(res.body.completed).toBe(true); + expect(res.body.lobby.xpBonusPercent).toBeCloseTo(0.50); + }); + + it('✅ Débloque FIRST_MULTI_SESSION pour chaque membre à la clôture', async () => { + const lobbyId = await createActiveLobby([alice, bob]); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + const res = await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`); + + expect(res.body.newlyUnlocked).toContain('FIRST_MULTI_SESSION'); + + const aliceUser = await User.findById(alice.userId); + expect(aliceUser.achievements.some((a) => a.achievementId === 'FIRST_MULTI_SESSION')).toBe(true); + }); + + it('✅ Débloque MULTI_SQUAD_FULL uniquement à 5 joueurs', async () => { + const lobbyId = await createActiveLobby([alice, bob]); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + const res = await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`); + + expect(res.body.newlyUnlocked).not.toContain('MULTI_SQUAD_FULL'); + }); + + it("❌ 422 si le lobby n'est pas encore actif (waiting)", async () => { + const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + const lobbyId = res.body.lobby._id; + + const finishRes = await request(app) + .post(`/api/lobby/${lobbyId}/finish`) + .set('Authorization', `Bearer ${alice.token}`); + expect(finishRes.statusCode).toBe(422); + }); + + it("❌ 403 si l'appelant ne fait pas partie du lobby", async () => { + const lobbyId = await createActiveLobby([alice, bob]); + + const res = await request(app) + .post(`/api/lobby/${lobbyId}/finish`) + .set('Authorization', `Bearer ${carol.token}`); + expect(res.statusCode).toBe(403); + }); + }); + + describe('GET /api/lobby/:id', () => { + it("✅ Un membre peut consulter l'état du lobby", async () => { + const createRes = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + const lobbyId = createRes.body.lobby._id; + + const res = await request(app) + .get(`/api/lobby/${lobbyId}`) + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.lobby.status).toBe('waiting'); + }); + + it("❌ 403 si l'appelant ne fait pas partie du lobby", async () => { + const createRes = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); + const lobbyId = createRes.body.lobby._id; + + const res = await request(app) + .get(`/api/lobby/${lobbyId}`) + .set('Authorization', `Bearer ${bob.token}`); + expect(res.statusCode).toBe(403); + }); + + it('❌ 404 si le lobby n\'existe pas', async () => { + const fakeId = new mongoose.Types.ObjectId(); + const res = await request(app) + .get(`/api/lobby/${fakeId}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(res.statusCode).toBe(404); + }); + }); +}); diff --git a/back/utils/profanityFilter.js b/back/utils/profanityFilter.js new file mode 100644 index 0000000..2402b56 --- /dev/null +++ b/back/utils/profanityFilter.js @@ -0,0 +1,64 @@ +'use strict'; + +// ─── Filtre anti-injures (Section VIII) ─────────────────────────────────────── +// Bloque les pseudos explicitement vulgaires/injurieux à la création de +// compte. Liste de mots clés locale (pas un service tiers), volontairement +// large pour couvrir insultes générales, vulgarités, racisme/xénophobie et +// homophobie (FR + EN) — chaque entrée est vérifiée pour ne pas être un +// substring d'un mot français courant (voir la liste d'exclusions ci-dessous). +// +// Normalisation avant comparaison : minuscules, accents retirés, séparateurs +// (espaces, underscores, chiffres utilisés comme lettres type "3"→"e") +// neutralisés — pour attraper les contournements les plus courants +// ("m3rde", "put_ain") sans faire une analyse linguistique complète. + +// Mots volontairement EXCLUS malgré leur proximité avec un terme banni, parce +// qu'ils sont substrings de mots français courants et provoqueraient des +// faux-positifs sur des pseudos légitimes : +// - "raton" → substring de "marathonien" (trophée existant, pseudo plausible) +// - "bride" → substring de "hybride" +// - "demeure"/"demeuré" → mot courant ("Belle Demeure"), pas assez spécifique +// - "attarde" → forme conjuguée courante de "s'attarder" +const BANNED_WORDS = [ + // ── Insultes générales (FR) ────────────────────────────────────────────── + 'connard', 'connasse', 'conne', 'batard', 'bâtard', 'abruti', 'abrutie', + 'crétin', 'cretin', 'imbécile', 'imbecile', 'débile', 'debile', + 'taré', 'trisomique', 'mongolien', 'ordure', 'raclure', 'connerie', + 'branleur', 'branleuse', 'pouffiasse', 'pouf', 'salopard', + + // ── Vulgarités / sexuel (FR) ───────────────────────────────────────────── + 'pute', 'putain', 'salope', 'merde', 'bordel', 'nique', 'niquer', 'enculé', + 'encule', 'enculee', 'enculer', 'bite', 'couille', 'couilles', 'chatte', + 'foutre', 'ntm', 'pd', 'pédé', 'pede', 'tapette', 'gouine', + 'tarlouze', 'enfoiré', 'enfoire', 'fdp', + + // ── Racisme / xénophobie (FR) ──────────────────────────────────────────── + 'negro', 'négro', 'negre', 'nègre', 'bougnoule', 'bounty', 'youpin', + 'niakoué', 'niakoue', 'chinetoque', 'yeux bridés', 'bamboula', + 'sale race', 'sale noir', 'sale arabe', 'sale juif', + + // ── Insultes courantes (EN) ────────────────────────────────────────────── + 'fuck', 'shit', 'bitch', 'asshole', 'jackass', 'dumbass', 'cunt', 'nigger', + 'nigga', 'whore', 'slut', 'faggot', 'retard', 'dick', 'pussy', 'cock', + 'motherfucker', 'bastard', 'twat', 'wanker', 'bollocks', 'douchebag', + 'moron', 'idiot', 'scumbag', 'chink', 'kike', 'gook', +]; + +const LEET_MAP = { 0: 'o', 1: 'i', 3: 'e', 4: 'a', 5: 's', 7: 't', '@': 'a', '$': 's' }; + +function normalize(text) { + let s = String(text || '').toLowerCase(); + s = s.replace(/[013457@$]/g, (ch) => LEET_MAP[ch] ?? ch); + s = s.normalize('NFD').replace(/[̀-ͯ]/g, ''); // accents + s = s.replace(/[^a-z]/g, ''); // ne garde que les lettres — colle les mots séparés par espace/underscore + return s; +} + +/** True si `text` contient un mot de la liste noire (après normalisation). */ +function containsProfanity(text) { + const normalized = normalize(text); + if (!normalized) return false; + return BANNED_WORDS.some((word) => normalized.includes(normalize(word))); +} + +module.exports = { containsProfanity, normalize }; diff --git a/back/validators/auth.validator.js b/back/validators/auth.validator.js index dcca35f..bbdc4ae 100644 --- a/back/validators/auth.validator.js +++ b/back/validators/auth.validator.js @@ -62,6 +62,12 @@ const schemas = { code: codeRule, newPassword: registerPwdRule, }), + + googleLogin: Joi.object({ + idToken: Joi.string().required().messages({ + "any.required": "idToken est obligatoire.", + }), + }), }; module.exports = schemas; diff --git a/back/validators/workoutLobby.validator.js b/back/validators/workoutLobby.validator.js new file mode 100644 index 0000000..daf126f --- /dev/null +++ b/back/validators/workoutLobby.validator.js @@ -0,0 +1,9 @@ +const Joi = require('joi'); + +const workoutLobbySchemas = { + inviteToLobby: Joi.object({ + friendId: Joi.string().required(), + }), +}; + +module.exports = workoutLobbySchemas; diff --git a/front/src/components/cards/ExerciseCard.js b/front/src/components/cards/ExerciseCard.js index ce63d0b..f5fa7d8 100644 --- a/front/src/components/cards/ExerciseCard.js +++ b/front/src/components/cards/ExerciseCard.js @@ -1,11 +1,10 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, Linking, - Alert, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; @@ -17,6 +16,8 @@ import { primaryEquipmentLabel, } from '../../constants/exerciseFilters'; import EquipmentTag from '../workouts/EquipmentTag'; +import InfoModal from '../common/InfoModal'; +import ActionSheetModal from '../common/ActionSheetModal'; // ExerciseCard pixel-perfect (maquette 1). // @@ -35,6 +36,8 @@ function ExerciseCard({ inSuperset = false, }) { const name = item && (item.name || item.title); + const [infoModal, setInfoModal] = useState(null); // { title, body } + const [actionSheetVisible, setActionSheetVisible] = useState(false); if (!name) return null; const icon = pickExerciseIcon(item); @@ -45,35 +48,32 @@ function ExerciseCard({ const openVideo = useCallback(async () => { if (!videoUrl) { - Alert.alert('Vidéo indisponible', "Aucun lien vidéo n'est associé à cet exercice."); + setInfoModal({ title: 'Vidéo indisponible', body: "Aucun lien vidéo n'est associé à cet exercice." }); return; } try { const can = await Linking.canOpenURL(videoUrl); if (can) await Linking.openURL(videoUrl); - else Alert.alert('Lien invalide', "Impossible d'ouvrir cette vidéo."); + else setInfoModal({ title: 'Lien invalide', body: "Impossible d'ouvrir cette vidéo." }); } catch (e) { - Alert.alert('Erreur', "Impossible d'ouvrir la vidéo."); + setInfoModal({ title: 'Erreur', body: "Impossible d'ouvrir la vidéo." }); } }, [videoUrl]); const showActions = useCallback(() => { try { Haptics.selectionAsync(); } catch (e) {} - const actions = []; - if (videoUrl) actions.push({ text: 'Voir la vidéo', onPress: openVideo }); - if (onReplace) actions.push({ text: 'Remplacer', onPress: () => onReplace(item) }); - if (onToggleSuperset) { - actions.push({ - text: inSuperset ? 'Sortir du superset' : 'Superset avec le suivant', - onPress: () => onToggleSuperset(item), - }); - } - if (onRemove) { - actions.push({ text: 'Supprimer', style: 'destructive', onPress: () => onRemove(item) }); - } - actions.push({ text: 'Annuler', style: 'cancel' }); - Alert.alert(name, null, actions); - }, [name, videoUrl, onReplace, onRemove, onToggleSuperset, inSuperset, item, openVideo]); + setActionSheetVisible(true); + }, []); + + const actionOptions = [ + ...(videoUrl ? [{ label: 'Voir la vidéo', onPress: openVideo }] : []), + ...(onReplace ? [{ label: 'Remplacer', onPress: () => onReplace(item) }] : []), + ...(onToggleSuperset ? [{ + label: inSuperset ? 'Sortir du superset' : 'Superset avec le suivant', + onPress: () => onToggleSuperset(item), + }] : []), + ...(onRemove ? [{ label: 'Supprimer', destructive: true, onPress: () => onRemove(item) }] : []), + ]; return (
+ + setActionSheetVisible(false)} + /> + setInfoModal(null)} + />
); } diff --git a/front/src/components/common/ActionSheetModal.js b/front/src/components/common/ActionSheetModal.js new file mode 100644 index 0000000..ef3f7d2 --- /dev/null +++ b/front/src/components/common/ActionSheetModal.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { Colors } from '../../constants/theme'; + +// ─── ActionSheetModal ───────────────────────────────────────────────────────── +// Menu d'actions à choix multiples (3+ options) — remplace +// Alert.alert(title, message, [{text, onPress, style}, ...]) quand il y a +// plus qu'un simple confirmer/annuler (ex: "Voir la vidéo" / "Remplacer" / +// "Supprimer" sur une carte d'exercice). +// +// Props : +// visible bool +// title string +// options Array<{ label: string, onPress?: () => void, destructive?: bool }> +// cancelLabel string (défaut "Annuler") +// onClose () => void — appelé après tout choix (y compris Annuler) + +export default function ActionSheetModal({ visible, title, options = [], cancelLabel = 'Annuler', onClose }) { + const handlePress = (opt) => { + onClose?.(); + opt.onPress?.(); + }; + + return ( + + + + {!!title && {title}} + + {options.map((opt, i) => ( + handlePress(opt)} + activeOpacity={0.75} + > + {opt.label} + + ))} + + + {cancelLabel} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'flex-end', + padding: 12, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 18, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + paddingTop: 18, + paddingHorizontal: 8, + paddingBottom: 8, + marginBottom: 8, + }, + title: { + color: Colors.textMuted, + fontSize: 12.5, + fontWeight: '700', + textAlign: 'center', + marginBottom: 10, + paddingHorizontal: 16, + }, + optionBtn: { + height: 50, + justifyContent: 'center', + alignItems: 'center', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(255,255,255,0.08)', + }, + optionBtnLast: { marginBottom: 8 }, + optionTxt: { color: Colors.textPrimary, fontSize: 15.5, fontWeight: '600' }, + optionTxtDestructive: { color: '#EF4444' }, + cancelBtn: { + height: 50, + justifyContent: 'center', + alignItems: 'center', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(255,255,255,0.08)', + }, + cancelTxt: { color: Colors.primary, fontSize: 15.5, fontWeight: '700' }, +}); diff --git a/front/src/components/common/ErrorBoundary.js b/front/src/components/common/ErrorBoundary.js index 783f998..57a1357 100644 --- a/front/src/components/common/ErrorBoundary.js +++ b/front/src/components/common/ErrorBoundary.js @@ -45,7 +45,7 @@ export default class ErrorBoundary extends React.Component {
Oups, une erreur est survenue - Pas de panique — tes données sont en sécurité.{'\n'}Réessaie, ça devrait repartir. + Pas de panique - tes données sont en sécurité.{'\n'}Réessaie, ça devrait repartir. Recharger l'application diff --git a/front/src/components/common/InfoModal.js b/front/src/components/common/InfoModal.js new file mode 100644 index 0000000..0aba99b --- /dev/null +++ b/front/src/components/common/InfoModal.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +// ─── InfoModal ──────────────────────────────────────────────────────────────── +// Alerte à un seul bouton (information, erreur, validation simple) — remplace +// Alert.alert(title, message) / Alert.alert(title, message, [{text:'OK'}]) +// par une modale graphique cohérente avec le reste de l'app. +// +// Props : +// visible bool +// icon string — nom Ionicons affiché dans le badge +// title string +// body string +// closeLabel string (défaut "OK") +// destructive bool — accent rouge si true (erreurs), orange sinon +// onClose () => void + +export default function InfoModal({ + visible, icon = 'information-circle-outline', title, body, + closeLabel = 'OK', destructive = false, onClose, +}) { + const accent = destructive ? '#EF4444' : Colors.primary; + + return ( + + + + + + + + {title} + {body ? {body} : null} + + + {closeLabel} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + padding: 28, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + iconWrap: { + width: 60, + height: 60, + borderRadius: 18, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 18, + }, + title: { + color: Colors.textPrimary, + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 10, + textAlign: 'center', + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 24, + }, + closeBtn: { + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + closeTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/components/home/RecoveryRitualsCard.js b/front/src/components/home/RecoveryRitualsCard.js index f0dec60..82c1d4b 100644 --- a/front/src/components/home/RecoveryRitualsCard.js +++ b/front/src/components/home/RecoveryRitualsCard.js @@ -48,7 +48,7 @@ Les 5 points clés d'un bon squat : 2. Descends jusqu'aux cuisses parallèles au sol minimum. Plus profond = plus de fessiers, moins de contrainte sur les genoux. -3. Garde le dos neutre — ni trop arqué, ni trop rond. Imagine une barre de fer sur ta colonne. +3. Garde le dos neutre - ni trop arqué, ni trop rond. Imagine une barre de fer sur ta colonne. 4. Pousse le sol vers le bas au retour, comme si tu voulais écarter le plancher avec tes pieds. Ça active les fessiers et stabilise les genoux. @@ -58,11 +58,11 @@ Erreur la plus commune : les genoux qui rentrent vers l'intérieur. Si c'est ton }, { title: 'Manger après l\'effort : ce qui compte vraiment', - content: `La "fenêtre anabolique" — cette idée qu'il faut absolument manger dans les 30 minutes après l'effort — est largement exagérée. Si tu as bien mangé 2-3h avant ta séance, cette fenêtre s'étend à 4-6h après l'entraînement. + content: `La "fenêtre anabolique" - cette idée qu'il faut absolument manger dans les 30 minutes après l'effort - est largement exagérée. Si tu as bien mangé 2-3h avant ta séance, cette fenêtre s'étend à 4-6h après l'entraînement. Ce qui compte vraiment après l'effort : -Protéines : 20 à 40g pour relancer la synthèse protéique. Ton corps n'utilise pas plus de 40g à la fois — inutile d'en avaler 80g. +Protéines : 20 à 40g pour relancer la synthèse protéique. Ton corps n'utilise pas plus de 40g à la fois - inutile d'en avaler 80g. Glucides : après un effort intense, ton stock de glycogène est épuisé. 1 à 1.5g par kg de poids corporel aide à le reconstituer rapidement (riz, patate douce, fruits). diff --git a/front/src/components/profile/AchievementShowcase.js b/front/src/components/profile/AchievementShowcase.js index 22a94fd..9a7f6f4 100644 --- a/front/src/components/profile/AchievementShowcase.js +++ b/front/src/components/profile/AchievementShowcase.js @@ -33,6 +33,8 @@ const ICON_BY_ID = { CHEST_50: 'gift', CHEST_100: 'trophy', CHEST_200: 'diamond', + FIRST_MULTI_SESSION: 'people', + MULTI_SQUAD_FULL: 'people-circle', }; const RARITY_BY_ID = { @@ -61,6 +63,8 @@ const TIER_BY_ID = { CHEST_50: 'gold', CHEST_100: 'platinum', CHEST_200: 'diamond', + FIRST_MULTI_SESSION: 'silver', + MULTI_SQUAD_FULL: 'platinum', }; // FRIENDSHIP_LEVEL_5 ("Lien de Sang") est la même récompense Rareté Unique diff --git a/front/src/components/profile/BirthdatePicker.js b/front/src/components/profile/BirthdatePicker.js index cb96974..22e8713 100644 --- a/front/src/components/profile/BirthdatePicker.js +++ b/front/src/components/profile/BirthdatePicker.js @@ -78,7 +78,7 @@ export default function BirthdatePicker({ value, onConfirm }) { const formattedValue = value ? `${pad2(value.getUTCDate())}/${pad2(value.getUTCMonth() + 1)}/${value.getUTCFullYear()}` : null; - const formattedPending = canContinue ? `${pad2(day)}/${pad2(month + 1)}/${year}` : '—'; + const formattedPending = canContinue ? `${pad2(day)}/${pad2(month + 1)}/${year}` : '-'; return ( <> diff --git a/front/src/components/profile/PersonalRecordsList.js b/front/src/components/profile/PersonalRecordsList.js index adedd58..cbc47bc 100644 --- a/front/src/components/profile/PersonalRecordsList.js +++ b/front/src/components/profile/PersonalRecordsList.js @@ -83,7 +83,7 @@ function RecordRow({ record, isLast, onPress }) { {record.prEstimate1RM != null && 1RM ~{record.prEstimate1RM}} ) : ( - + - )}
{daysToNext} jour{daysToNext > 1 ? 's' : ''} - {' '}avant ×{nextMilestone.multiplier} — {nextMilestone.label} + {' '}avant ×{nextMilestone.multiplier} - {nextMilestone.label} )} diff --git a/front/src/components/social/FriendPreviewModal.js b/front/src/components/social/FriendPreviewModal.js index 38af755..58e16ec 100644 --- a/front/src/components/social/FriendPreviewModal.js +++ b/front/src/components/social/FriendPreviewModal.js @@ -75,7 +75,7 @@ export default function FriendPreviewModal({ visible, result, onSend, onClose }) {relationStatus === 'pending_received' && ( - Vous a envoyé une invitation — vois l'onglet Demandes + Vous a envoyé une invitation - vois l'onglet Demandes )} {relationStatus === 'accepted' && ( diff --git a/front/src/components/stats/PRCard.js b/front/src/components/stats/PRCard.js index 8d3ee9a..4aa9ef2 100644 --- a/front/src/components/stats/PRCard.js +++ b/front/src/components/stats/PRCard.js @@ -13,9 +13,9 @@ export default function PRCard({ prWeight = 0, prVolume = 0, prEstimate1RM = 0, Records personnels - - - + + + diff --git a/front/src/components/stats/WorkoutHistoryList.js b/front/src/components/stats/WorkoutHistoryList.js index 8667b9d..bdc7679 100644 --- a/front/src/components/stats/WorkoutHistoryList.js +++ b/front/src/components/stats/WorkoutHistoryList.js @@ -1,10 +1,11 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; import { View, Text, FlatList, TouchableOpacity, StyleSheet, - Animated, Alert, Platform, UIManager, + Animated, Platform, UIManager, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; +import ConfirmModal from '../common/ConfirmModal'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); @@ -27,7 +28,7 @@ function SetRow({ set, index }) { reps × - {set.weight > 0 ? `${set.weight} kg` : '—'} + {set.weight > 0 ? `${set.weight} kg` : '-'} ); @@ -65,6 +66,7 @@ function ExerciseBlock({ exercise, isLast }) { function SessionCard({ log, onDelete }) { const [expanded, setExpanded] = useState(false); const [contentVisible, setContentVisible] = useState(false); + const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false); const anim = useRef(new Animated.Value(0)).current; const toggle = useCallback(() => { @@ -80,15 +82,10 @@ function SessionCard({ log, onDelete }) { }); }, [expanded, anim]); - const handleDelete = useCallback(() => { - Alert.alert( - 'Supprimer la séance', - `Supprimer "${log.name}" de l'historique ? Cette action est irréversible.`, - [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Supprimer', style: 'destructive', onPress: () => onDelete(log.id) }, - ], - ); + const handleDelete = useCallback(() => setDeleteConfirmVisible(true), []); + const confirmDelete = useCallback(() => { + setDeleteConfirmVisible(false); + onDelete(log.id); }, [log, onDelete]); const exercises = useMemo( @@ -104,7 +101,7 @@ function SessionCard({ log, onDelete }) { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', }); } catch { - return log.date?.slice(0, 10) ?? '—'; + return log.date?.slice(0, 10) ?? '-'; } }, [log.date]); @@ -177,6 +174,17 @@ function SessionCard({ log, onDelete }) { )} + + setDeleteConfirmVisible(false)} + />
); } diff --git a/front/src/components/workouts/ExerciseHeader.js b/front/src/components/workouts/ExerciseHeader.js index 4bd0c99..bec7f83 100644 --- a/front/src/components/workouts/ExerciseHeader.js +++ b/front/src/components/workouts/ExerciseHeader.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { View, Text, @@ -6,10 +6,10 @@ import { TouchableOpacity, StyleSheet, Linking, - Alert, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; +import InfoModal from '../common/InfoModal'; const LOGO = require('../../../assets/minimalist-logo-orange.png'); @@ -29,15 +29,17 @@ function ChronoPill({ timer }) { } export default function ExerciseHeader({ timer = '0:00', videoUrl }) { + const [infoModal, setInfoModal] = useState(null); // { title, body } + const onPlayPress = async () => { if (!videoUrl) { - Alert.alert('Vidéo indisponible', "Aucun lien vidéo n'est associé à cet exercice."); + setInfoModal({ title: 'Vidéo indisponible', body: "Aucun lien vidéo n'est associé à cet exercice." }); return; } try { await Linking.openURL(videoUrl); } catch (e) { - Alert.alert('Erreur', "Impossible d'ouvrir la vidéo."); + setInfoModal({ title: 'Erreur', body: "Impossible d'ouvrir la vidéo." }); } }; @@ -63,6 +65,14 @@ export default function ExerciseHeader({ timer = '0:00', videoUrl }) {
+ + setInfoModal(null)} + />
); } diff --git a/front/src/components/workouts/InlineExerciseBlock.js b/front/src/components/workouts/InlineExerciseBlock.js index 7fd93bd..a4e1f93 100644 --- a/front/src/components/workouts/InlineExerciseBlock.js +++ b/front/src/components/workouts/InlineExerciseBlock.js @@ -1,10 +1,9 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, - Alert, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; @@ -16,6 +15,7 @@ import { pickExerciseIcon, } from '../../constants/exerciseFilters'; import SetTable from './SetTable'; +import ActionSheetModal from '../common/ActionSheetModal'; // Bloc exercice pour la vue "Voir tous les exercices" (all-in-one). // Affiche le titre, les muscles, le SetTable compact et le bouton [+ Série]. @@ -62,22 +62,21 @@ function InlineExerciseBlock({ exercise, exerciseIndex, onRemoveExercise, onRepl actions.removeSet(exerciseIndex, i); }, [actions, exerciseIndex]); + const [actionSheetVisible, setActionSheetVisible] = useState(false); + const showActions = useCallback(() => { try { Haptics.selectionAsync(); } catch (_) {} - const opts = []; - if (onReplaceExercise) { - opts.push({ text: 'Remplacer', onPress: () => onReplaceExercise(exerciseIndex) }); - } - if (onRemoveExercise) { - opts.push({ - text: 'Supprimer', - style: 'destructive', - onPress: () => onRemoveExercise(exerciseIndex, exercise), - }); - } - opts.push({ text: 'Annuler', style: 'cancel' }); - Alert.alert(title, null, opts); - }, [title, onReplaceExercise, onRemoveExercise, exerciseIndex, exercise]); + setActionSheetVisible(true); + }, []); + + const actionOptions = [ + ...(onReplaceExercise ? [{ label: 'Remplacer', onPress: () => onReplaceExercise(exerciseIndex) }] : []), + ...(onRemoveExercise ? [{ + label: 'Supprimer', + destructive: true, + onPress: () => onRemoveExercise(exerciseIndex, exercise), + }] : []), + ]; return ( @@ -134,6 +133,12 @@ function InlineExerciseBlock({ exercise, exerciseIndex, onRemoveExercise, onRepl Ajouter une série + setActionSheetVisible(false)} + /> ); } diff --git a/front/src/components/workouts/LobbyInviteCheck.js b/front/src/components/workouts/LobbyInviteCheck.js new file mode 100644 index 0000000..12550c2 --- /dev/null +++ b/front/src/components/workouts/LobbyInviteCheck.js @@ -0,0 +1,69 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; +import { useAuth } from '../../context/AuthContext'; +import { joinLobby } from '../../services/lobby.service'; +import { navigate } from '../../navigation/navigationRef'; +import LobbyInviteModal from './LobbyInviteModal'; + +// ─── LobbyInviteCheck ───────────────────────────────────────────────────────── +// À monter une fois dans l'arbre authentifié (AppNavigator), au même niveau +// que WeightReminderCheck / ActivityFeedModal. Écoute les notifications push +// `lobby_invite` (voir back/services/push.service.js) : +// - reçue au premier plan → popup custom "X t'invite..." (Rejoindre/Ignorer) +// - tapée depuis l'arrière-plan/fermée → rejoint directement, sans popup +// (l'utilisateur a déjà exprimé son intention en tapant la notification) + +export default function LobbyInviteCheck() { + const { userToken } = useAuth(); + const [invite, setInvite] = useState(null); // { lobbyId, fromPseudo } + const joiningRef = useRef(false); + + const handleJoinLobby = useCallback(async (lobbyId) => { + if (!lobbyId || joiningRef.current) return; + joiningRef.current = true; + try { await joinLobby(lobbyId); } catch (_) {} + joiningRef.current = false; + navigate('Séances', { screen: 'WorkoutList', params: { pendingLobbyId: lobbyId } }); + }, []); + + useEffect(() => { + if (Platform.OS === 'web' || !userToken) return undefined; + + const receivedSub = Notifications.addNotificationReceivedListener((notification) => { + const data = notification?.request?.content?.data; + if (data?.type === 'lobby_invite' && data?.lobbyId) { + setInvite({ lobbyId: data.lobbyId, fromPseudo: data.fromPseudo }); + } + }); + + const responseSub = Notifications.addNotificationResponseReceivedListener((response) => { + const data = response?.notification?.request?.content?.data; + if (data?.type === 'lobby_invite' && data?.lobbyId) { + handleJoinLobby(data.lobbyId); + } + }); + + return () => { + receivedSub.remove(); + responseSub.remove(); + }; + }, [userToken, handleJoinLobby]); + + const onJoin = useCallback(() => { + const lobbyId = invite?.lobbyId; + setInvite(null); + handleJoinLobby(lobbyId); + }, [invite, handleJoinLobby]); + + const onDismiss = useCallback(() => setInvite(null), []); + + return ( + + ); +} diff --git a/front/src/components/workouts/LobbyInviteModal.js b/front/src/components/workouts/LobbyInviteModal.js new file mode 100644 index 0000000..11b0bed --- /dev/null +++ b/front/src/components/workouts/LobbyInviteModal.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +// ─── LobbyInviteModal ───────────────────────────────────────────────────────── +// Popup affichée quand une notification push `lobby_invite` est reçue pendant +// que l'app est au premier plan (voir LobbyInviteCheck) — remplace tout +// Alert.alert natif, cohérent avec le reste de l'app. +// +// Props : +// visible bool +// fromPseudo string +// onJoin () => void — rejoint le lobby +// onDismiss () => void — ignore l'invitation + +export default function LobbyInviteModal({ visible, fromPseudo, onJoin, onDismiss }) { + return ( + + + + + + + + Invitation Multi + + {fromPseudo || 'Un ami'} t'invite à réaliser une séance ensemble ! + + + + + Ignorer + + + Rejoindre + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 28, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + iconWrap: { + width: 56, height: 56, borderRadius: 16, + backgroundColor: `${Colors.primary}1A`, + borderWidth: 1, borderColor: `${Colors.primary}45`, + justifyContent: 'center', alignItems: 'center', marginBottom: 16, + }, + title: { color: Colors.textPrimary, fontSize: 18, fontWeight: '800', marginBottom: 10, textAlign: 'center' }, + body: { color: Colors.textSecondary, fontSize: 14, lineHeight: 20, textAlign: 'center', marginBottom: 24 }, + btnRow: { flexDirection: 'row', width: '100%', gap: 10 }, + dismissBtn: { + flex: 1, height: 50, borderRadius: 13, + justifyContent: 'center', alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', + }, + dismissBtnTxt: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700' }, + joinBtn: { + flex: 1, height: 50, borderRadius: 13, + justifyContent: 'center', alignItems: 'center', backgroundColor: Colors.primary, + shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, shadowRadius: 12, elevation: 6, + }, + joinBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700' }, +}); diff --git a/front/src/components/workouts/LobbyMembersBar.js b/front/src/components/workouts/LobbyMembersBar.js new file mode 100644 index 0000000..b7325bb --- /dev/null +++ b/front/src/components/workouts/LobbyMembersBar.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +const STATUS_META = { + waiting: { icon: 'time-outline', color: Colors.textMuted }, + ready: { icon: 'flash', color: '#FBBF24' }, + finished: { icon: 'checkmark-circle', color: Colors.success }, +}; + +// ─── LobbyMembersBar ────────────────────────────────────────────────────────── +// Bulles des participants connectés au lobby (Section VII), affichées en haut +// de l'écran de séance pendant l'effort — chacun gère ses séries de son côté +// (résilience réseau), cette barre est juste un indicateur de présence/statut, +// rafraîchie par polling (voir WorkoutScreen.js). +// +// Props : members Array<{ user: {_id, pseudo}, status }> + +export default function LobbyMembersBar({ members = [] }) { + if (members.length === 0) return null; + + return ( + + + {members.map((m) => { + const meta = STATUS_META[m.status] ?? STATUS_META.waiting; + return ( + + {(m.user.pseudo ?? '?').charAt(0).toUpperCase()} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 16, paddingVertical: 8, + }, + bubble: { + width: 26, height: 26, borderRadius: 8, + backgroundColor: 'rgba(254,116,57,0.16)', + justifyContent: 'center', alignItems: 'center', + marginRight: 6, borderWidth: 1.5, borderColor: Colors.background, + }, + bubbleTxt: { color: Colors.primary, fontSize: 11, fontWeight: '800' }, + dot: { + position: 'absolute', bottom: -2, right: -2, + width: 8, height: 8, borderRadius: 4, + borderWidth: 1, borderColor: Colors.background, + }, +}); diff --git a/front/src/components/workouts/LobbyWaitingOverlay.js b/front/src/components/workouts/LobbyWaitingOverlay.js new file mode 100644 index 0000000..4ccc661 --- /dev/null +++ b/front/src/components/workouts/LobbyWaitingOverlay.js @@ -0,0 +1,83 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +const STATUS_LABEL = { + waiting: 'En cours…', + ready: 'En cours…', + finished: 'Terminé !', +}; + +// ─── LobbyWaitingOverlay ────────────────────────────────────────────────────── +// Écran de clôture synchrone (Section VII) : dès que l'utilisateur clique +// "Finir la séance", son statut passe 'finished' et l'app bloque son écran +// ici jusqu'à ce que TOUS les membres du lobby aient fini — moment où le +// parent (WorkoutScreen.js) détecte lobby.status === 'completed' et bascule +// vers MultiLootModal. +// +// Modale plein écran non-fermable (pas de bouton retour) — c'est voulu : la +// séance est déjà validée, il n'y a rien à annuler. + +export default function LobbyWaitingOverlay({ visible, members = [] }) { + return ( + + + + + En attente de tes partenaires... + + Ta séance est enregistrée. Le butin d'équipe sera distribué dès que + tout le monde aura terminé. + + + + {members.map((m) => ( + + {m.user.pseudo} + + {m.status === 'finished' + ? + : } + + {STATUS_LABEL[m.status] ?? 'En cours…'} + + + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.92)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 28, + alignItems: 'center', + }, + title: { color: Colors.textPrimary, fontSize: 18, fontWeight: '800', marginBottom: 10, textAlign: 'center' }, + body: { color: Colors.textSecondary, fontSize: 13, lineHeight: 19, textAlign: 'center', marginBottom: 22 }, + list: { width: '100%', gap: 4 }, + row: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingVertical: 10, paddingHorizontal: 4, + borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.06)', + }, + pseudo: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '700', flex: 1, marginRight: 10 }, + statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + statusTxt: { color: Colors.textMuted, fontSize: 12, fontWeight: '600' }, +}); diff --git a/front/src/components/workouts/MultiLobbyModal.js b/front/src/components/workouts/MultiLobbyModal.js new file mode 100644 index 0000000..d597d8f --- /dev/null +++ b/front/src/components/workouts/MultiLobbyModal.js @@ -0,0 +1,443 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity, ActivityIndicator, FlatList } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; +import { createLobby, getLobby, joinLobby, inviteToLobby, readyLobby, unreadyLobby } from '../../services/lobby.service'; +import { getFriendsList } from '../../services/social.service'; +import { useUser } from '../../context/UserContext'; + +const POLL_MS = 3000; +const INVITE_COOLDOWN_MS = 30000; + +const STATUS_META = { + waiting: { icon: 'time-outline', color: Colors.textMuted }, + ready: { icon: 'checkmark-circle', color: Colors.success }, + finished: { icon: 'flag', color: Colors.primary }, +}; + +// Bonus XP Multi — miroir de computeMultiBonusPercent (back/workoutLobby.controller.js) : +// généreux à dessein, un groupe de 5 est rare (2 joueurs → 15%, 3 → 25%, +// 4 → 35%, 5 → 50%). +const MULTI_BONUS_BY_COUNT = { 1: 0, 2: 0.15, 3: 0.25, 4: 0.35, 5: 0.50 }; +function computeBonusPercent(memberCount) { + if (memberCount >= 5) return MULTI_BONUS_BY_COUNT[5]; + return MULTI_BONUS_BY_COUNT[memberCount] ?? 0; +} + +const BONUS_TABLE = [1, 2, 3, 4, 5].map((n) => ({ count: n, percent: computeBonusPercent(n) })); + +// ─── BonusTableModal ────────────────────────────────────────────────────────── +// Petit tableau expliquant le bonus XP de groupe par effectif — ouvert en +// appuyant sur le chip de bonus dans MultiLobbyModal. +function BonusTableModal({ visible, memberCount, onClose }) { + return ( + + + + + + + + + + + Bonus XP de groupe + + Plus vous êtes nombreux, plus le bonus grimpe. Assembler un groupe de 5 est difficile - ça se mérite ! + + + + {BONUS_TABLE.map((row) => ( + + + {row.count} joueur{row.count > 1 ? 's' : ''} + + + +{Math.round(row.percent * 100)}% + + + ))} + + + + + ); +} + +// ─── MultiLobbyModal ────────────────────────────────────────────────────────── +// Étape de lancement d'une séance en Multi (Section VII) : crée le lobby (ou +// rejoint un lobby existant depuis une invitation reçue), permet d'inviter des +// amis (vraie notification push, avec cooldown anti-spam de 30s), affiche qui +// a rejoint et son statut ainsi que le bonus XP de groupe, et se ferme +// automatiquement (onReady) dès que le lobby passe 'active' — c'est-à-dire dès +// que 100% des membres sont prêts. +// +// Props : +// visible bool +// existingLobbyId string | null — rejoint ce lobby au lieu d'en créer un neuf +// onClose () => void — annule (le lobby reste en base, abandonné) +// onReady (lobbyId: string) => void — lobby actif, lance la séance + +export default function MultiLobbyModal({ visible, existingLobbyId, onClose, onReady }) { + const { user } = useUser(); + const myId = user?._id; + + const [lobby, setLobby] = useState(null); + const [loading, setLoading] = useState(true); + const [friends, setFriends] = useState([]); + const [invitePanelOpen, setInvitePanelOpen] = useState(false); + const [readying, setReadying] = useState(false); + const [bonusTableVisible, setBonusTableVisible] = useState(false); + const [invitedAt, setInvitedAt] = useState({}); // { friendId: timestampMs } + const [now, setNow] = useState(Date.now()); + const pollRef = useRef(null); + const cooldownTickRef = useRef(null); + const firedReadyRef = useRef(false); + + useEffect(() => { + if (!visible) return; + firedReadyRef.current = false; + setLoading(true); + setInvitePanelOpen(false); + setInvitedAt({}); + + (async () => { + try { + const [lobbyRes, friendsRes] = await Promise.all([ + existingLobbyId ? joinLobby(existingLobbyId) : createLobby(), + getFriendsList(), + ]); + setLobby(lobbyRes.lobby); + setFriends(friendsRes.friends ?? []); + } catch (_) { + // Best-effort — fermeture silencieuse si la création/jointure échoue. + } finally { + setLoading(false); + } + })(); + + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, [visible, existingLobbyId]); + + // Poll l'état du lobby — pas de websocket, cohérent avec le reste de l'app + // (Météo des séances, Groupe de streak...). + useEffect(() => { + if (!visible || !lobby?._id) return; + pollRef.current = setInterval(async () => { + try { + const res = await getLobby(lobby._id); + setLobby(res.lobby); + if (res.lobby.status === 'active' && !firedReadyRef.current) { + firedReadyRef.current = true; + clearInterval(pollRef.current); + onReady(res.lobby._id); + } + } catch (_) { + // best-effort + } + }, POLL_MS); + return () => clearInterval(pollRef.current); + }, [visible, lobby?._id, onReady]); + + // Tick pour rafraîchir les cercles de cooldown d'invitation (1x/s). + useEffect(() => { + if (!visible) return undefined; + cooldownTickRef.current = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(cooldownTickRef.current); + }, [visible]); + + const handleInvite = useCallback(async (friendId) => { + if (!lobby) return; + const startedAt = invitedAt[friendId]; + if (startedAt && Date.now() - startedAt < INVITE_COOLDOWN_MS) return; + setInvitedAt((prev) => ({ ...prev, [friendId]: Date.now() })); + try { await inviteToLobby(lobby._id, friendId); } catch (_) {} + }, [lobby, invitedAt]); + + const myMember = (lobby?.members ?? []).find((m) => m.user._id === myId); + const isReady = myMember?.status === 'ready'; + const canReady = (lobby?.memberCount ?? 0) >= 2; + + const handleToggleReady = useCallback(async () => { + if (!lobby) return; + setReadying(true); + try { + const res = isReady ? await unreadyLobby(lobby._id) : await readyLobby(lobby._id); + setLobby(res.lobby); + if (res.lobby.status === 'active' && !firedReadyRef.current) { + firedReadyRef.current = true; + onReady(res.lobby._id); + } + } catch (_) { + // best-effort + } finally { + setReadying(false); + } + }, [lobby, isReady, onReady]); + + const memberIds = new Set((lobby?.members ?? []).map((m) => m.user._id)); + const invitableFriends = friends.filter((f) => !memberIds.has(f.user._id)); + const bonusPercent = computeBonusPercent(lobby?.memberCount ?? 1); + + return ( + + + + + + + + + + + Séance en Multi + + Invite jusqu'à 4 amis. La séance démarre pour tout le monde dès que + chacun se déclare prêt. + + + {loading ? ( + + ) : ( + <> + + {(lobby?.members ?? []).map((m) => { + const meta = STATUS_META[m.status] ?? STATUS_META.waiting; + return ( + + + {(m.user.pseudo ?? '?').charAt(0).toUpperCase()} + + + + + {m.user.pseudo} + + ); + })} + + + setBonusTableVisible(true)} activeOpacity={0.8}> + + Bonus de groupe : +{Math.round(bonusPercent * 100)}% XP + + + + setInvitePanelOpen((v) => !v)} + activeOpacity={0.8} + > + + Inviter un ami + + + + {invitePanelOpen && ( + + {invitableFriends.length === 0 ? ( + Tous tes amis sont déjà dans le lobby (ou tu n'as pas d'amis disponibles). + ) : ( + f.user._id} + style={{ maxHeight: 160 }} + renderItem={({ item }) => { + const startedAt = invitedAt[item.user._id]; + const elapsedMs = startedAt ? now - startedAt : Infinity; + const onCooldown = elapsedMs < INVITE_COOLDOWN_MS; + const remainingSec = onCooldown ? Math.ceil((INVITE_COOLDOWN_MS - elapsedMs) / 1000) : 0; + return ( + handleInvite(item.user._id)} + activeOpacity={0.75} + disabled={onCooldown} + > + {item.user.pseudo} + {onCooldown ? ( + + {remainingSec} + + ) : ( + + )} + + ); + }} + /> + )} + + )} + + + {readying + ? + : ( + <> + + + {isReady ? 'Je ne suis plus prêt' : 'Je suis prêt'} + + + )} + + + {canReady + ? 'En attente que tout le monde soit prêt pour démarrer ensemble…' + : 'Il faut au moins 2 joueurs dans le lobby pour se déclarer prêt.'} + + + )} + + + + setBonusTableVisible(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 24, + paddingTop: 36, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + closeIcon: { position: 'absolute', top: 14, right: 14, zIndex: 1 }, + iconWrap: { + width: 56, height: 56, borderRadius: 16, + backgroundColor: `${Colors.primary}1A`, + borderWidth: 1, borderColor: `${Colors.primary}45`, + justifyContent: 'center', alignItems: 'center', marginBottom: 14, + }, + title: { color: Colors.textPrimary, fontSize: 18, fontWeight: '800', marginBottom: 8, textAlign: 'center' }, + body: { color: Colors.textSecondary, fontSize: 13, lineHeight: 19, textAlign: 'center', marginBottom: 18 }, + membersRow: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 14, marginBottom: 18, width: '100%' }, + memberBubbleWrap: { alignItems: 'center', width: 56 }, + memberBubble: { + width: 44, height: 44, borderRadius: 14, + backgroundColor: 'rgba(254,116,57,0.14)', + justifyContent: 'center', alignItems: 'center', + }, + memberBubbleTxt: { color: Colors.primary, fontSize: 16, fontWeight: '800' }, + statusDot: { + position: 'absolute', bottom: -4, right: -4, + width: 16, height: 16, borderRadius: 8, + borderWidth: 1.5, borderColor: '#13131C', + justifyContent: 'center', alignItems: 'center', + }, + memberName: { color: Colors.textMuted, fontSize: 10.5, fontWeight: '600', marginTop: 5, textAlign: 'center' }, + bonusChip: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + height: 38, width: '100%', borderRadius: 12, + backgroundColor: Colors.primary, marginBottom: 12, + }, + bonusChipTxt: { color: '#fff', fontSize: 12.5, fontWeight: '800' }, + inviteToggle: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + height: 42, width: '100%', borderRadius: 12, + borderWidth: 1, borderColor: Colors.borderSubtle, + backgroundColor: 'rgba(255,255,255,0.03)', marginBottom: 8, gap: 6, + }, + inviteToggleTxt: { color: Colors.textPrimary, fontSize: 13, fontWeight: '700' }, + invitePanel: { width: '100%', marginBottom: 12 }, + inviteEmpty: { color: Colors.textMuted, fontSize: 12, textAlign: 'center', paddingVertical: 10 }, + friendRow: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingVertical: 10, paddingHorizontal: 4, + borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.06)', + }, + friendName: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '600' }, + cooldownCircle: { + width: 20, height: 20, borderRadius: 10, + borderWidth: 1.5, borderColor: Colors.textMuted, + justifyContent: 'center', alignItems: 'center', + }, + cooldownTxt: { color: Colors.textMuted, fontSize: 9.5, fontWeight: '700' }, + readyBtn: { + flexDirection: 'row', width: '100%', height: 50, borderRadius: 13, + justifyContent: 'center', alignItems: 'center', backgroundColor: Colors.primary, + marginTop: 4, shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, shadowRadius: 12, elevation: 6, + }, + unreadyBtn: { + backgroundColor: 'rgba(255,255,255,0.08)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.16)', + shadowColor: 'transparent', shadowOpacity: 0, elevation: 0, + }, + readyBtnDisabled: { + backgroundColor: 'rgba(255,255,255,0.06)', + shadowColor: 'transparent', shadowOpacity: 0, elevation: 0, + }, + readyBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + waitHint: { color: Colors.textMuted, fontSize: 11, textAlign: 'center', marginTop: 10 }, + + // ── BonusTableModal ────────────────────────────────────────────────────── + tableCard: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 24, + paddingTop: 36, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + tableRows: { width: '100%', gap: 8 }, + tableRow: { + flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', + paddingVertical: 10, paddingHorizontal: 14, borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.03)', + }, + tableRowActive: { + backgroundColor: `${Colors.primary}1A`, + borderWidth: 1, borderColor: `${Colors.primary}45`, + }, + tableRowLabel: { color: Colors.textSecondary, fontSize: 13.5, fontWeight: '600' }, + tableRowLabelActive: { color: Colors.textPrimary, fontWeight: '800' }, + tableRowPercent: { color: Colors.textMuted, fontSize: 14, fontWeight: '800' }, + tableRowPercentActive: { color: Colors.primary }, +}); diff --git a/front/src/components/workouts/MultiLootModal.js b/front/src/components/workouts/MultiLootModal.js new file mode 100644 index 0000000..651f6e3 --- /dev/null +++ b/front/src/components/workouts/MultiLootModal.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +// ─── MultiLootModal ──────────────────────────────────────────────────────────── +// Popup d'équipe explosive (Section VII) : affichée dès que le dernier membre +// du lobby termine sa séance — le salon passe 'completed', tout le monde se +// déverrouille simultanément et voit le bonus XP Multi appliqué. +// +// Props : +// visible bool +// memberCount number +// bonusPercent number (0..0.50) +// bonusXp number — montant XP réellement crédité +// onClose () => void + +export default function MultiLootModal({ visible, memberCount, bonusPercent, bonusXp, onClose }) { + return ( + + + + + + + + SÉANCE D'ÉQUIPE TERMINÉE + Butin distribué ! + + Vous avez terminé cette séance à {memberCount} - l'effort collectif paie. + + + + + + Bonus Multi +{Math.round(bonusPercent * 100)}% {bonusXp != null ? `(+${bonusXp} XP)` : ''} + + + + + Nickel ! + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 24, + borderWidth: 1, + borderColor: 'rgba(255,215,0,0.4)', + padding: 28, + alignItems: 'center', + shadowColor: Colors.gold, + shadowOffset: { width: 0, height: 20 }, + shadowOpacity: 0.5, + shadowRadius: 40, + elevation: 24, + }, + iconWrap: { + width: 76, height: 76, borderRadius: 24, + backgroundColor: 'rgba(255,215,0,0.12)', + borderWidth: 1, borderColor: 'rgba(255,215,0,0.4)', + justifyContent: 'center', alignItems: 'center', marginBottom: 16, + }, + eyebrow: { color: Colors.textMuted, fontSize: 11, fontWeight: '800', letterSpacing: 1.5, marginBottom: 6 }, + title: { color: Colors.textPrimary, fontSize: 21, fontWeight: '900', marginBottom: 10, textAlign: 'center' }, + body: { color: Colors.textSecondary, fontSize: 13.5, lineHeight: 20, textAlign: 'center', marginBottom: 20 }, + bonusChip: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + backgroundColor: Colors.primary, borderRadius: 13, + paddingVertical: 12, paddingHorizontal: 18, marginBottom: 22, width: '100%', + shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, shadowRadius: 12, elevation: 6, + }, + bonusChipTxt: { color: '#fff', fontSize: 14.5, fontWeight: '800' }, + closeBtn: { + width: '100%', height: 50, borderRadius: 13, + justifyContent: 'center', alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', + }, + closeBtnTxt: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700' }, +}); diff --git a/front/src/components/workouts/WorkoutRecapModal.js b/front/src/components/workouts/WorkoutRecapModal.js index 983cb48..43b2073 100644 --- a/front/src/components/workouts/WorkoutRecapModal.js +++ b/front/src/components/workouts/WorkoutRecapModal.js @@ -441,7 +441,7 @@ export default function WorkoutRecapModal({ - Limite quotidienne atteinte — reviens demain ! + Limite quotidienne atteinte - reviens demain ! ) : ( @@ -530,7 +530,7 @@ export default function WorkoutRecapModal({ {pr.name} - {pr.oldPR || '—'} kg + {pr.oldPR || '-'} kg {pr.newPR} kg diff --git a/front/src/data/trophyCatalog.js b/front/src/data/trophyCatalog.js index 8676615..4d302e6 100644 --- a/front/src/data/trophyCatalog.js +++ b/front/src/data/trophyCatalog.js @@ -54,19 +54,19 @@ export function weeklyConsecutive(logs, weeks) { export const TROPHY_CATALOG = [ // ─── HÉRITAGE (6) ────────────────────────────────────────────────────────── { id: 'ignition', category: 'heritage', icon: 'flame', label: 'Ignition', condition: '1ère séance', - epicDesc: "La flamme s'allume. Votre premier pas dans l'arène — et rien ne sera jamais plus pareil.", + epicDesc: "La flamme s'allume. Votre premier pas dans l'arène - et rien ne sera jamais plus pareil.", color: '#FE7439', gradientColors: ['#FF9A5C','#FE7439','#C44A10'], tier: 'bronze', check: (l, s) => s >= 1 }, { id: 'promise', category: 'heritage', icon: 'medal', label: 'Promesse', condition: 'Niveau 10', - epicDesc: "Le novice est mort. Vous avez prouvé que vous êtes là pour durer — la promesse est tenue.", + epicDesc: "Le novice est mort. Vous avez prouvé que vous êtes là pour durer - la promesse est tenue.", color: '#FFD700', gradientColors: ['#FFE566','#FFD700','#B8860B'], tier: 'gold', check: (l) => l >= 10 }, { id: 'apprenti', category: 'heritage', icon: 'school', label: 'Apprenti', condition: 'Niveau 25', - epicDesc: "Les bases sont posées. Chaque set vous a sculpté — vous n'êtes plus un débutant, vous êtes un apprenti.", + epicDesc: "Les bases sont posées. Chaque set vous a sculpté - vous n'êtes plus un débutant, vous êtes un apprenti.", color: '#34D399', gradientColors: ['#6EE7B7','#34D399','#059669'], tier: 'silver', check: (l) => l >= 25 }, { id: 'centurion', category: 'heritage', icon: 'trophy', label: 'Centurion', condition: '50 séances', - epicDesc: "Votre volonté est d'acier. 50 combats menés avec honneur — les légions vous saluent.", + epicDesc: "Votre volonté est d'acier. 50 combats menés avec honneur - les légions vous saluent.", color: '#6E6AF0', gradientColors: ['#9B97FF','#6E6AF0','#3D3A9E'], tier: 'platinum', check: (l, s) => s >= 50 }, { id: 'veteran', category: 'heritage', icon: 'shield-checkmark', label: 'Vétéran', condition: 'Niveau 75', @@ -110,7 +110,7 @@ export const TROPHY_CATALOG = [ // ─── EXPLORATION (6) ─────────────────────────────────────────────────────── { id: 'polyvalent', category: 'exploration', icon: 'grid', label: 'Polyvalent', condition: '5 groupes musculaires', - epicDesc: "Pecs, dos, jambes, épaules, bras — vous ne laissez aucun muscle au repos.", + epicDesc: "Pecs, dos, jambes, épaules, bras - vous ne laissez aucun muscle au repos.", color: '#22C55E', gradientColors: ['#4ADE80','#22C55E','#15803D'], tier: 'silver', check: (l, s, logs) => new Set(logs.flatMap(log => Object.keys(log.muscleDistribution || {}))).size >= 5 }, { id: 'marathonien', category: 'exploration', icon: 'time', label: 'Marathonien', condition: 'Séance ≥ 60 min', @@ -118,7 +118,7 @@ export const TROPHY_CATALOG = [ color: '#0EA5E9', gradientColors: ['#38BDF8','#0EA5E9','#0369A1'], tier: 'bronze', check: (l, s, logs) => logs.some(log => (Number(log.durationSeconds) || 0) >= 3600) }, { id: 'demi_legende', category: 'exploration', icon: 'rocket', label: 'Demi-Légende', condition: 'Niveau 50', - epicDesc: "La moitié du chemin vers le sommet. Peu y arrivent — vous y êtes.", + epicDesc: "La moitié du chemin vers le sommet. Peu y arrivent - vous y êtes.", color: '#3B82F6', gradientColors: ['#60A5FA','#3B82F6','#1D4ED8'], tier: 'gold', check: (l) => l >= 50 }, { id: 'xp_millionaire', category: 'exploration', icon: 'infinite', label: 'XP Millionnaire', condition: '1 000 000 XP cumulés', @@ -168,13 +168,13 @@ export const TROPHY_CATALOG = [ color: '#4338CA', gradientColors: ['#6366F1','#4338CA','#1E1B4B'], tier: 'silver', check: (l, s, logs) => logs.some(log => { if (!log.date) return false; return new Date(log.date).getHours() === 0; }) }, { id: 'athly_god', category: 'secret', icon: 'planet', label: 'ATHLY GOD', condition: 'Niveau 200', - epicDesc: "Le sommet absolu. Vous n'êtes plus un athlète — vous êtes une légende vivante. Le trône vous appartient.", + epicDesc: "Le sommet absolu. Vous n'êtes plus un athlète - vous êtes une légende vivante. Le trône vous appartient.", color: '#FFD700', gradientColors: ['#FDE68A','#FFD700','#92400E'], tier: 'diamond', check: (l) => l >= 200 }, // ─── CORPS (5) ───────────────────────────────────────────────────────────── { id: 'corps_bronze', category: 'corps', icon: 'fitness', label: 'Initié Poids Corps', condition: '50 sets complétés', - epicDesc: "Cinquante séries avec votre propre corps. Pas de barres, pas de charges — juste vous contre la gravité.", + epicDesc: "Cinquante séries avec votre propre corps. Pas de barres, pas de charges - juste vous contre la gravité.", color: '#CD7F32', gradientColors: ['#E8A060','#CD7F32','#6B3A1A'], tier: 'bronze', check: (l, s, logs) => logs.reduce((sum, log) => sum + (Number(log.setsCompleted) || 0), 0) >= 50 }, { id: 'corps_silver', category: 'corps', icon: 'walk', label: 'Guerrier Poids Corps', condition: '150 sets complétés', @@ -196,7 +196,7 @@ export const TROPHY_CATALOG = [ // ─── RÉGULARITÉ (5) ──────────────────────────────────────────────────────── { id: 'reg_3m', category: 'regularite', icon: 'calendar-outline', label: 'Constance 3 Mois', condition: 'Séances sur 3 mois', - epicDesc: "Trois mois d'entraînement. Pas une mode — une véritable habitude.", + epicDesc: "Trois mois d'entraînement. Pas une mode - une véritable habitude.", color: '#10B981', gradientColors: ['#34D399','#10B981','#065F46'], tier: 'bronze', check: (l, s, logs) => daysSpanned(logs) >= 90 }, { id: 'reg_6m', category: 'regularite', icon: 'time-outline', label: 'Constance 6 Mois', condition: 'Séances sur 6 mois', @@ -222,13 +222,13 @@ export const TROPHY_CATALOG = [ color: '#EC4899', gradientColors: ['#F472B6','#EC4899','#9D174D'], tier: 'bronze', check: () => false }, { id: 'mentor', category: 'social', icon: 'people-circle', label: 'Mentor', condition: 'Inspirer 5 amis', - epicDesc: "Vous avez allumé la flamme chez cinq autres — vous êtes plus qu'un athlète, vous êtes un mentor.", + epicDesc: "Vous avez allumé la flamme chez cinq autres - vous êtes plus qu'un athlète, vous êtes un mentor.", color: '#8B5CF6', gradientColors: ['#A78BFA','#8B5CF6','#4C1D95'], tier: 'gold', check: () => false }, // ─── SPÉCIAL (1) ─────────────────────────────────────────────────────────── { id: 'athly_birthday', category: 'special', icon: 'gift', label: 'Anniversaire Athly', condition: 'Séance le 13 mai', - epicDesc: "Le jour où Athly est né, vous étiez là — à suer, à pousser, à vous dépasser.", + epicDesc: "Le jour où Athly est né, vous étiez là - à suer, à pousser, à vous dépasser.", color: '#FE7439', gradientColors: ['#FF9A5C','#FE7439','#C44A10'], tier: 'gold', check: (l, s, logs) => logs.some(log => { if (!log.date) return false; @@ -245,7 +245,7 @@ export const ULTIMATE_TROPHY = { icon: 'infinite', label: 'Souverain Absolu', condition: 'Tous les trophées débloqués', - epicDesc: "Il n'existe pas de plus grand accomplissement. Vous avez tout conquis, tout maîtrisé, tout surpassé. L'empire d'Athly vous appartient — et l'univers entier s'incline devant vous.", + epicDesc: "Il n'existe pas de plus grand accomplissement. Vous avez tout conquis, tout maîtrisé, tout surpassé. L'empire d'Athly vous appartient - et l'univers entier s'incline devant vous.", color: '#FFD700', gradientColors: ['#FFFACD', '#FFD700', '#FF8C00', '#C44A10'], tier: 'diamond', diff --git a/front/src/data/tutorialChapters.js b/front/src/data/tutorialChapters.js index 23c72cc..b9551e8 100644 --- a/front/src/data/tutorialChapters.js +++ b/front/src/data/tutorialChapters.js @@ -94,7 +94,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'workout_anticheat', title: 'Séances Sérieuses Uniquement', - body: "Athly récompense l'effort réel : une séance de moins de 5 minutes ne rapporte aucun XP. Tu peux quand même l'enregistrer — mais seules les séances complètes comptent pour ta streak.", + body: "Athly récompense l'effort réel : une séance de moins de 5 minutes ne rapporte aucun XP. Tu peux quand même l'enregistrer - mais seules les séances complètes comptent pour ta streak.", targetKey: null, position: 'center', scrollY: null, }, { @@ -252,7 +252,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'settings_done', title: 'Tu es prêt, Athlète !', - body: "Tu maîtrises désormais Athly. Lance-toi — chaque set te rapproche du sommet. Bonne chance !", + body: "Tu maîtrises désormais Athly. Lance-toi - chaque set te rapproche du sommet. Bonne chance !", targetKey: null, position: 'center', scrollY: null, isLast: true, }, diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js index 5dcd224..c1d0963 100644 --- a/front/src/navigation/index.js +++ b/front/src/navigation/index.js @@ -8,6 +8,7 @@ import { ActivityIndicator, View } from 'react-native'; import AuthStack from './AuthStack'; import BottomTabs from './BottomTabs'; +import { navigationRef } from './navigationRef'; import { Colors } from '../constants/theme'; import { useAuth } from '../context/AuthContext'; @@ -24,6 +25,7 @@ import BirthdayCelebration from '../components/profile/BirthdayCelebration'; import LevelUpCelebration from '../components/profile/LevelUpCelebration'; import ActivityFeedModal from '../components/social/ActivityFeedModal'; import WeightReminderCheck from '../components/stats/WeightReminderCheck'; +import LobbyInviteCheck from '../components/workouts/LobbyInviteCheck'; const NOTIF_ENABLED_KEY = 'athly:notif:enabled:v1'; @@ -68,7 +70,7 @@ export default function AppNavigator() { } return ( - + @@ -81,6 +83,7 @@ export default function AppNavigator() { + )} diff --git a/front/src/navigation/navigationRef.js b/front/src/navigation/navigationRef.js new file mode 100644 index 0000000..b1c65f0 --- /dev/null +++ b/front/src/navigation/navigationRef.js @@ -0,0 +1,12 @@ +import { createNavigationContainerRef } from '@react-navigation/native'; + +// Réf globale du conteneur de navigation — permet de naviguer depuis des +// composants montés hors de toute pile (ex: LobbyInviteCheck, qui réagit à +// une notification push reçue au premier plan et n'a pas de prop `navigation`). +export const navigationRef = createNavigationContainerRef(); + +export function navigate(name, params) { + if (navigationRef.isReady()) { + navigationRef.navigate(name, params); + } +} diff --git a/front/src/screens/Auth/ForgotPasswordScreen.js b/front/src/screens/Auth/ForgotPasswordScreen.js index e6c603d..3057934 100644 --- a/front/src/screens/Auth/ForgotPasswordScreen.js +++ b/front/src/screens/Auth/ForgotPasswordScreen.js @@ -388,7 +388,7 @@ export default function ForgotPasswordScreen({ navigation }) { {/* ══════════════════════════════════════════════════ - ÉTAPE 1 — Saisie de l'email + ÉTAPE 1 - Saisie de l'email ══════════════════════════════════════════════════ */} {step === 1 && ( <> @@ -438,7 +438,7 @@ export default function ForgotPasswordScreen({ navigation }) { )} {/* ══════════════════════════════════════════════════ - ÉTAPE 2 — Code + Nouveau mot de passe + ÉTAPE 2 - Code + Nouveau mot de passe ══════════════════════════════════════════════════ */} {step === 2 && ( <> diff --git a/front/src/screens/Auth/RegisterScreen.js b/front/src/screens/Auth/RegisterScreen.js index bae3cfa..b5a7b5a 100644 --- a/front/src/screens/Auth/RegisterScreen.js +++ b/front/src/screens/Auth/RegisterScreen.js @@ -200,6 +200,85 @@ const wm = StyleSheet.create({ }, }); +// ─── Popup "Pseudo non autorisé" ────────────────────────────────────────────── +// Ton volontairement plus ferme que WeakPasswordModal (qui reste un conseil) : +// ici on avertit explicitement que le pseudo a été refusé par la modération +// automatique et qu'une insistance répétée peut mener à des restrictions de +// compte — dissuasif sans bloquer techniquement la nouvelle tentative. + +function PseudoRejectedModal({ visible, onClose }) { + return ( + + + + + + + + Pseudo non autorisé + + Ce pseudo a été refusé car il contient un terme injurieux ou inapproprié. + Athly est une communauté respectueuse - merci d'en choisir un autre. + + + Toute tentative répétée avec un pseudo de ce type pourra entraîner des + restrictions sur ton compte. + + + + Choisir un autre pseudo + + + + + ); +} + +const pm = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: '#13131C', + borderRadius: 22, + borderWidth: 1, + borderColor: 'rgba(239,68,68,0.35)', + padding: 28, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.6, + shadowRadius: 32, + elevation: 20, + }, + iconWrap: { + width: 60, height: 60, borderRadius: 18, + backgroundColor: 'rgba(239,68,68,0.12)', + borderWidth: 1, borderColor: 'rgba(239,68,68,0.3)', + justifyContent: 'center', alignItems: 'center', marginBottom: 18, + }, + title: { color: Colors.textPrimary, fontSize: 19, fontWeight: '800', letterSpacing: -0.3, marginBottom: 12, textAlign: 'center' }, + body: { color: Colors.textSecondary, fontSize: 14, lineHeight: 21, textAlign: 'center', marginBottom: 14 }, + warning: { color: '#EF4444', fontSize: 12.5, fontWeight: '700', lineHeight: 18, textAlign: 'center', marginBottom: 24 }, + closeBtn: { + width: '100%', height: 50, borderRadius: 13, + backgroundColor: '#EF4444', justifyContent: 'center', alignItems: 'center', + shadowColor: '#EF4444', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 6, + }, + closeTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); + // ─── Écran principal ────────────────────────────────────────────────────────── export default function RegisterScreen({ navigation }) { const [pseudo, setPseudo] = useState(''); @@ -220,6 +299,7 @@ export default function RegisterScreen({ navigation }) { const [errType, setErrType] = useState('error'); const [weakModalVisible, setWeakModalVisible] = useState(false); + const [pseudoRejectedVisible, setPseudoRejectedVisible] = useState(false); // ── Règles allégées : seul le minimum absolu bloque le bouton ──────────────── const isPwdMinimal = password.length >= MIN_PWD; @@ -277,6 +357,9 @@ export default function RegisterScreen({ navigation }) { } else if (msg.toLowerCase().includes('email')) { setErrType('error'); setGlobalErr('Cet email est déjà utilisé.'); + } else if (msg.toLowerCase().includes('pseudo')) { + setPseudoErr('Pseudo non autorisé'); + setPseudoRejectedVisible(true); } else { setErrType('error'); setGlobalErr("Erreur lors de l'inscription. Réessayez."); @@ -424,6 +507,11 @@ export default function RegisterScreen({ navigation }) { onCreate={doRegister} loading={loading} /> + + { setPseudoRejectedVisible(false); setPseudo(''); }} + /> ); } diff --git a/front/src/screens/Home/HomeScreen.js b/front/src/screens/Home/HomeScreen.js index ba7b77b..c8aba2c 100644 --- a/front/src/screens/Home/HomeScreen.js +++ b/front/src/screens/Home/HomeScreen.js @@ -179,7 +179,7 @@ export default function HomeScreen({ navigation }) { {isTutorialDashboard && ( - Données de démonstration — disparaîtront à la fin du chapitre + Données de démonstration - disparaîtront à la fin du chapitre )} set('poids', v)} - placeholder="—" + placeholder="-" placeholderTextColor={Colors.textMuted} selectionColor={Colors.primary} keyboardType="decimal-pad" @@ -239,7 +239,7 @@ export default function EditProfileScreen({ navigation }) { style={styles.inlineInput} value={formData.poidsCible} onChangeText={(v) => set('poidsCible', v)} - placeholder="—" + placeholder="-" placeholderTextColor={Colors.textMuted} selectionColor={Colors.primary} keyboardType="decimal-pad" @@ -250,7 +250,7 @@ export default function EditProfileScreen({ navigation }) { style={styles.inlineInput} value={formData.taille} onChangeText={(v) => set('taille', v)} - placeholder="—" + placeholder="-" placeholderTextColor={Colors.textMuted} selectionColor={Colors.primary} keyboardType="number-pad" diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js index 2f175cc..454f203 100644 --- a/front/src/screens/Profile/ProfileScreen.js +++ b/front/src/screens/Profile/ProfileScreen.js @@ -339,7 +339,7 @@ export default function ProfileScreen({ navigation }) {
{/* ── Activité 12 mois ── */} -
+
diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index d6b7e9d..3a2c468 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -1,11 +1,13 @@ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, - StatusBar, Switch, Alert, TextInput, ActivityIndicator, Modal, Share, + StatusBar, Switch, TextInput, ActivityIndicator, Modal, Share, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Colors } from '../../constants/theme'; +import ConfirmModal from '../../components/common/ConfirmModal'; +import InfoModal from '../../components/common/InfoModal'; import { useAuth } from '../../context/AuthContext'; import { useUser } from '../../context/UserContext'; import { useToast } from '../../context/ToastContext'; @@ -41,7 +43,7 @@ import { syncBackendLevel, giveChests, generateMockSocial, giveAllItems, simulateChestsOpened, simulateReferral, simulateBirthday, simulateGroup, simulateActivityEvent, simulateStreakBreak, simulateShakeSelf, - simulateSearchableFriend, + simulateSearchableFriend, simulateLobbyInvite, } from '../../services/debug.service'; const UNIT_WEIGHT_KEY = 'athly:unit:weight:v1'; @@ -160,6 +162,7 @@ export default function SettingsScreen({ navigation }) { const [simFeedback, setSimFeedback] = useState(''); const [trophyExpanded, setTrophyExpanded] = useState(false); const [testFriendTag, setTestFriendTag] = useState(null); + const [infoModal, setInfoModal] = useState(null); // { title, body, destructive? } const [deleteModal1, setDeleteModal1] = useState(false); const [deleteModal2, setDeleteModal2] = useState(false); @@ -222,13 +225,13 @@ export default function SettingsScreen({ navigation }) { if (val) { const granted = await requestNotificationPermissions(); if (!granted) { - Alert.alert('Notifications désactivées', 'Activez les notifications Athly dans les réglages de votre appareil.'); + setInfoModal({ title: 'Notifications désactivées', body: 'Activez les notifications Athly dans les réglages de votre appareil.' }); return; } try { await scheduleDailyReminder(); } catch (e) { - Alert.alert('Erreur', 'Impossible de planifier la notification.'); + setInfoModal({ title: 'Erreur', body: 'Impossible de planifier la notification.', destructive: true }); return; } setNotifEnabled(true); @@ -261,15 +264,11 @@ export default function SettingsScreen({ navigation }) { const handleGodMode = useCallback(async (val) => { await setGodMode(val); - if (val) Alert.alert('God Mode activé', 'Utilisez la console ci-dessous pour simuler votre progression.'); + if (val) setInfoModal({ title: 'God Mode activé', body: 'Utilisez la console ci-dessous pour simuler votre progression.' }); }, [setGodMode]); - const handleLogout = () => { - Alert.alert('Déconnexion', 'Tu vas être déconnecté.', [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Déconnexion', style: 'destructive', onPress: signOut }, - ]); - }; + const [logoutConfirmVisible, setLogoutConfirmVisible] = useState(false); + const handleLogout = () => setLogoutConfirmVisible(true); const runSim = useCallback(async (fn, successMsg) => { try { @@ -309,11 +308,11 @@ export default function SettingsScreen({ navigation }) { runSim(() => debugSetStreak(n), `Streak ${n} jours appliqué ✓`); }; const handleResetDailyXP = () => runSim(debugResetDailyXP, 'Quota XP quotidien réinitialisé ✓'); - const handleClearDebug = () => { - Alert.alert('Effacer les logs DEBUG', 'Les vraies séances restent intactes.', [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Effacer', style: 'destructive', onPress: () => runSim(debugClearDebugLogs, 'Logs DEBUG effacés ✓') }, - ]); + const [clearDebugConfirmVisible, setClearDebugConfirmVisible] = useState(false); + const handleClearDebug = () => setClearDebugConfirmVisible(true); + const confirmClearDebug = () => { + setClearDebugConfirmVisible(false); + runSim(debugClearDebugLogs, 'Logs DEBUG effacés ✓'); }; const handleClearOverrides = () => { clearTrophyOverrides(); @@ -386,7 +385,7 @@ export default function SettingsScreen({ navigation }) { // de façon persistante, tant que ce compte de test reste valide. if (res.tag) { setTestFriendTag(res.tag); - showFeedback('Compte de test créé — tag affiché ci-dessous ↓'); + showFeedback('Compte de test créé - tag affiché ci-dessous ↓'); } else { showFeedback(res.message || 'Compte de test créé'); } @@ -398,6 +397,23 @@ export default function SettingsScreen({ navigation }) { } }, [showFeedback]); + // Crée un lobby Multi avec un coéquipier factice (isTestBot) et envoie une + // vraie notification push d'invitation à soi-même — seul moyen de tester en + // solo le parcours complet Lobby Multi (invitation, rejoindre, prêt, séance, + // bonus de groupe). Bloqué en production (404). + const handleSimulateLobbyInvite = useCallback(async () => { + try { + setSimLoading(true); + const res = await simulateLobbyInvite(); + showFeedback(res.message || 'Invitation de test créée'); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback]); + // Injecte 1 exemplaire de CHAQUE objet existant (consommables + cosmétiques // Uniques réclamables) pour tout tester en un clic. Bloqué en production (404). const handleGiveAllItems = useCallback(async () => { @@ -886,7 +902,7 @@ export default function SettingsScreen({ navigation }) { Crée un compte "TestAmi" PAS déjà ami avec un # généré aléatoirement. Le tag exact reste affiché juste en dessous (pas le message temporaire, - trop court pour changer d'écran) — saisis-le tel quel dans "Ajouter un + trop court pour changer d'écran) - saisis-le tel quel dans "Ajouter un ami" côté Social. Ne tape jamais "0000", ce n'est qu'un exemple de format. {testFriendTag && ( @@ -909,14 +925,14 @@ export default function SettingsScreen({ navigation }) { Crée un groupe avec 3 coéquipiers factices, un par statut de Météo des - séances testable (🔥 Prêt / ⚡ Actif / ✅ Validé — le 4e, 💤 En sommeil, + séances testable (🔥 Prêt / ⚡ Actif / ✅ Validé - le 4e, 💤 En sommeil, s'obtient en ne touchant à aucun des trois). Rejouable sans doublons. - Publie un PR battu ou un coffre Légendaire au nom d'un coéquipier — + Publie un PR battu ou un coffre Légendaire au nom d'un coéquipier - déclenche l'ActivityFeedModal au prochain lancement. Nécessite d'avoir d'abord simulé un groupe. @@ -932,7 +948,21 @@ export default function SettingsScreen({ navigation }) { Envoie une vraie notification push à ton propre appareil, avec le texte - troll du bouton Secouer — vérifie l'infra push de bout en bout. + troll du bouton Secouer - vérifie l'infra push de bout en bout. + + + + {/* ── LOBBY MULTI (V2) ── */} + + + + + + Crée un lobby avec un coéquipier factice et t'envoie une vraie + notification push d'invitation - teste tout le parcours en solo : + popup "X t'invite", rejoindre, se déclarer prêt, faire sa séance, + et voir le bonus XP de groupe à la fin (le coéquipier factice suit + automatiquement chacun de tes statuts). @@ -1242,6 +1272,37 @@ export default function SettingsScreen({ navigation }) { + + setInfoModal(null)} + /> + + { setLogoutConfirmVisible(false); signOut(); }} + onCancel={() => setLogoutConfirmVisible(false)} + /> + + setClearDebugConfirmVisible(false)} + /> ); } diff --git a/front/src/screens/Profile/TrophyRoomScreen.js b/front/src/screens/Profile/TrophyRoomScreen.js index 78279a7..0b25209 100644 --- a/front/src/screens/Profile/TrophyRoomScreen.js +++ b/front/src/screens/Profile/TrophyRoomScreen.js @@ -146,7 +146,7 @@ function UltimateTile({ unlocked, unlockedCount, totalCount, onPress }) { {ULTIMATE_TROPHY.label} - {unlocked ? 'Collection complète — Vous régnez.' : ULTIMATE_TROPHY.condition} + {unlocked ? 'Collection complète - Vous régnez.' : ULTIMATE_TROPHY.condition} {unlocked && ( @@ -464,12 +464,12 @@ function TrophyDetailModal({ trophy, onClose, isFeatured = false, onToggleFeatur - {unlocked ? `${tier ? tier.charAt(0).toUpperCase() + tier.slice(1) : ''} — Débloqué` : 'Verrouillé'} + {unlocked ? `${tier ? tier.charAt(0).toUpperCase() + tier.slice(1) : ''} - Débloqué` : 'Verrouillé'} - {unlocked ? epicDesc : "Accomplissez encore — ce trophée attend le guerrier que vous deviendrez."} + {unlocked ? epicDesc : "Accomplissez encore - ce trophée attend le guerrier que vous deviendrez."} {unlocked && !isBackend && ( { @@ -874,7 +874,7 @@ function UserRow({ user, children }) { )} - {user?.pseudo ?? '—'} + {user?.pseudo ?? '-'} Nv. {user?.level ?? 1} · {user?.rank ?? 'Novice'} {weather ? ` · ${weather.label}` : ''} diff --git a/front/src/screens/Stats/StatsScreen.js b/front/src/screens/Stats/StatsScreen.js index 18241ac..7ef02db 100644 --- a/front/src/screens/Stats/StatsScreen.js +++ b/front/src/screens/Stats/StatsScreen.js @@ -1,6 +1,6 @@ import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { - View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, + View, Text, ScrollView, TouchableOpacity, StyleSheet, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; @@ -19,6 +19,8 @@ import XPProgressBar from '../../components/stats/XPProgressBar'; import WorkoutHistoryList from '../../components/stats/WorkoutHistoryList'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import WeightEntryModal from '../../components/common/WeightEntryModal'; +import ConfirmModal from '../../components/common/ConfirmModal'; +import InfoModal from '../../components/common/InfoModal'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; import { MOCK_TUTORIAL_LOGS } from '../../data/mockTutorialStats'; @@ -31,9 +33,13 @@ export default function StatsScreen({ navigation }) { const { sessionLogs: realLogs, totalXP, remove } = useWorkoutLogs(); const { user } = useUser(); + const [errorInfo, setErrorInfo] = useState(null); + const [dayDetail, setDayDetail] = useState(null); // { log, body, deletable } + const [noSessionInfo, setNoSessionInfo] = useState(null); // dateKey + const handleDelete = useCallback(async (id) => { try { await remove(id); } catch (e) { - Alert.alert('Erreur', e?.message || 'Suppression impossible'); + setErrorInfo(e?.message || 'Suppression impossible'); } }, [remove]); @@ -118,26 +124,25 @@ export default function StatsScreen({ navigation }) { const onSelectDate = useCallback((dateKey) => { const matching = activeLogs.filter((l) => l.date && l.date.slice(0, 10) === dateKey); if (matching.length === 0) { - Alert.alert('Aucune séance', `Pas de séance le ${dateKey}.`); + setNoSessionInfo(dateKey); return; } const log = matching[0]; - Alert.alert( - log.name, - [`Volume: ${Math.round(log.totalVolume)} kg`, `Sets: ${log.setsCompleted}`, `XP: ${log.xpEarned}`].join('\n'), - [ - { text: 'Fermer', style: 'cancel' }, - ...(activeChapterId !== 'stats' ? [{ - text: 'Supprimer', style: 'destructive', - onPress: async () => { - try { await remove(log.id); } catch (e) { - Alert.alert('Erreur', e?.message || 'Suppression impossible'); - } - }, - }] : []), - ], - ); - }, [activeLogs, remove, activeChapterId]); + setDayDetail({ + log, + body: [`Volume: ${Math.round(log.totalVolume)} kg`, `Sets: ${log.setsCompleted}`, `XP: ${log.xpEarned}`].join('\n'), + deletable: activeChapterId !== 'stats', + }); + }, [activeLogs, activeChapterId]); + + const confirmDeleteDayDetail = useCallback(async () => { + const log = dayDetail?.log; + setDayDetail(null); + if (!log) return; + try { await remove(log.id); } catch (e) { + setErrorInfo(e?.message || 'Suppression impossible'); + } + }, [dayDetail, remove]); return ( @@ -145,7 +150,7 @@ export default function StatsScreen({ navigation }) { {activeChapterId === 'stats' && ( - Données de démonstration — disparaîtront à la fin du chapitre + Données de démonstration - disparaîtront à la fin du chapitre )} @@ -245,6 +250,46 @@ export default function StatsScreen({ navigation }) { onClose={() => setWeightEntryVisible(false)} onSaved={loadWeightHistory} /> + + {dayDetail?.deletable ? ( + setDayDetail(null)} + /> + ) : ( + setDayDetail(null)} + /> + )} + + setNoSessionInfo(null)} + /> + + setErrorInfo(null)} + /> ); } diff --git a/front/src/screens/Workouts/CustomExercisesScreen.js b/front/src/screens/Workouts/CustomExercisesScreen.js index e5ba89f..6ab409c 100644 --- a/front/src/screens/Workouts/CustomExercisesScreen.js +++ b/front/src/screens/Workouts/CustomExercisesScreen.js @@ -1,11 +1,10 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, FlatList, - Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; @@ -17,9 +16,13 @@ import { secondaryMusclesLabels, primaryEquipmentLabel, } from '../../constants/exerciseFilters'; +import ConfirmModal from '../../components/common/ConfirmModal'; +import InfoModal from '../../components/common/InfoModal'; export default function CustomExercisesScreen({ navigation }) { const { items, loading, remove } = useCustomExercises(); + const [deleteTarget, setDeleteTarget] = useState(null); + const [errorInfo, setErrorInfo] = useState(null); const onAdd = useCallback(() => { navigation && navigation.navigate('EditExercise', { mode: 'create' }); @@ -29,24 +32,15 @@ export default function CustomExercisesScreen({ navigation }) { navigation && navigation.navigate('EditExercise', { mode: 'edit', exerciseId: item.id }); }, [navigation]); - const onDelete = useCallback((item) => { - Alert.alert( - 'Supprimer', - `Supprimer "${item.name}" ? Les séances qui l'utilisent ne seront pas affectées.`, - [ - { text: 'Annuler', style: 'cancel' }, - { - text: 'Supprimer', - style: 'destructive', - onPress: async () => { - try { await remove(item.id); } catch (e) { - Alert.alert('Erreur', e && e.message ? e.message : 'Suppression impossible'); - } - }, - }, - ], - ); - }, [remove]); + const onDelete = useCallback((item) => setDeleteTarget(item), []); + + const confirmDelete = useCallback(async () => { + const item = deleteTarget; + setDeleteTarget(null); + try { await remove(item.id); } catch (e) { + setErrorInfo(e && e.message ? e.message : 'Suppression impossible'); + } + }, [deleteTarget, remove]); const renderItem = ({ item }) => { const icon = pickExerciseIcon(item); @@ -122,6 +116,25 @@ export default function CustomExercisesScreen({ navigation }) { ItemSeparatorComponent={() => } /> )} + + setDeleteTarget(null)} + /> + setErrorInfo(null)} + /> ); } diff --git a/front/src/screens/Workouts/EditExerciseScreen.js b/front/src/screens/Workouts/EditExerciseScreen.js index 962ecbb..bd0e057 100644 --- a/front/src/screens/Workouts/EditExerciseScreen.js +++ b/front/src/screens/Workouts/EditExerciseScreen.js @@ -6,7 +6,6 @@ import { TouchableOpacity, ScrollView, StyleSheet, - Alert, KeyboardAvoidingView, Platform, ActivityIndicator, @@ -18,6 +17,8 @@ import { EQUIPMENTS, LEVELS } from '../../constants/exerciseFilters'; import SelectableChip from '../../components/workouts/SelectableChip'; import MuscleHierarchyPicker from '../../components/workouts/MuscleHierarchyPicker'; import { useCustomExercises } from '../../context/CustomExercisesContext'; +import ConfirmModal from '../../components/common/ConfirmModal'; +import InfoModal from '../../components/common/InfoModal'; // Form add/edit d'un exercice perso. Aligné sur la structure du catalogue (sous-muscles // précis), donc directement compatible avec le Builder et l'algo de tri. @@ -49,6 +50,8 @@ export default function EditExerciseScreen({ route, navigation }) { const [videoUrl, setVideoUrl] = useState(existing ? existing.videoUrl || '' : ''); const [notes, setNotes] = useState(existing ? existing.notes || '' : ''); const [saving, setSaving] = useState(false); + const [infoModal, setInfoModal] = useState(null); // { title, body } + const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false); const toggle = useCallback((arr, value) => ( arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] @@ -65,11 +68,11 @@ export default function EditExerciseScreen({ route, navigation }) { const handleSave = useCallback(async () => { if (!name.trim()) { - Alert.alert('Nom requis', 'Donne un nom à ton exercice.'); + setInfoModal({ title: 'Nom requis', body: 'Donne un nom à ton exercice.' }); return; } if (!targetMuscle.trim()) { - Alert.alert('Muscle principal requis', 'Sélectionne le sous-muscle principal travaillé.'); + setInfoModal({ title: 'Muscle principal requis', body: 'Sélectionne le sous-muscle principal travaillé.' }); return; } setSaving(true); @@ -90,7 +93,7 @@ export default function EditExerciseScreen({ route, navigation }) { } if (navigation) navigation.goBack(); } catch (e) { - Alert.alert('Erreur', e && e.message ? e.message : 'Sauvegarde impossible'); + setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Sauvegarde impossible' }); } finally { setSaving(false); } @@ -98,26 +101,18 @@ export default function EditExerciseScreen({ route, navigation }) { const handleDelete = useCallback(() => { if (!exerciseId) return; - Alert.alert( - 'Supprimer', - `Supprimer "${name || 'cet exercice'}" ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { - text: 'Supprimer', - style: 'destructive', - onPress: async () => { - try { - await remove(exerciseId); - if (navigation) navigation.goBack(); - } catch (e) { - Alert.alert('Erreur', e && e.message ? e.message : 'Suppression impossible'); - } - }, - }, - ], - ); - }, [exerciseId, name, remove, navigation]); + setDeleteConfirmVisible(true); + }, [exerciseId]); + + const confirmDelete = useCallback(async () => { + setDeleteConfirmVisible(false); + try { + await remove(exerciseId); + if (navigation) navigation.goBack(); + } catch (e) { + setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Suppression impossible' }); + } + }, [exerciseId, remove, navigation]); return ( @@ -262,6 +257,25 @@ export default function EditExerciseScreen({ route, navigation }) { ) : null} + + setDeleteConfirmVisible(false)} + /> + setInfoModal(null)} + /> ); } diff --git a/front/src/screens/Workouts/ExerciseStatsScreen.js b/front/src/screens/Workouts/ExerciseStatsScreen.js index 9918d9b..c08cd4c 100644 --- a/front/src/screens/Workouts/ExerciseStatsScreen.js +++ b/front/src/screens/Workouts/ExerciseStatsScreen.js @@ -61,8 +61,8 @@ export default function ExerciseStatsScreen({ route, navigation }) { > - - + + @@ -138,7 +138,7 @@ function LastSessionCard({ session }) { {w > 0 ? `${w} kg` : 'PC'} - {r > 0 ? r : '—'} + {r > 0 ? r : '-'} { const c = Math.max(1, Math.min(20, count || 4)); @@ -167,11 +168,11 @@ export default function ManualWorkoutCreatorScreen({ navigation }) { const handleSave = useCallback(async () => { const trimmed = name.trim(); if (!trimmed) { - Alert.alert('Nom requis', 'Donne un nom à ta séance.'); + setInfoModal({ title: 'Nom requis', body: 'Donne un nom à ta séance.' }); return; } if (exercises.length === 0) { - Alert.alert('Au moins un exercice', 'Ajoute au moins un exercice avant de sauvegarder.'); + setInfoModal({ title: 'Au moins un exercice', body: 'Ajoute au moins un exercice avant de sauvegarder.' }); return; } setSaving(true); @@ -182,20 +183,24 @@ export default function ManualWorkoutCreatorScreen({ navigation }) { exercises, isManual: true, }); - Alert.alert( - 'Séance créée', - `"${trimmed}" est dans tes séances. Tu peux la lancer depuis la page Séances.`, - [ - { text: 'OK', onPress: () => navigation && navigation.goBack() }, - ], - ); + setInfoModal({ + title: 'Séance créée', + body: `"${trimmed}" est dans tes séances. Tu peux la lancer depuis la page Séances.`, + onCloseNav: true, + }); } catch (e) { - Alert.alert('Erreur', e && e.message ? e.message : 'Sauvegarde impossible'); + setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Sauvegarde impossible' }); } finally { setSaving(false); } }, [name, exercises, createSavedWorkout, navigation]); + const closeInfoModal = useCallback(() => { + const shouldGoBack = infoModal?.onCloseNav; + setInfoModal(null); + if (shouldGoBack && navigation) navigation.goBack(); + }, [infoModal, navigation]); + return ( + + ); } diff --git a/front/src/screens/Workouts/WorkoutBuilderScreen.js b/front/src/screens/Workouts/WorkoutBuilderScreen.js index c332a0b..2495497 100644 --- a/front/src/screens/Workouts/WorkoutBuilderScreen.js +++ b/front/src/screens/Workouts/WorkoutBuilderScreen.js @@ -5,7 +5,6 @@ import { TouchableOpacity, ScrollView, StyleSheet, - Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; @@ -21,6 +20,7 @@ import { useCustomExercises } from '../../context/CustomExercisesContext'; import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext'; import { useSavedWorkouts } from '../../context/SavedWorkoutsContext'; import { generateWorkout } from '../../data/exerciseCatalog'; +import InfoModal from '../../components/common/InfoModal'; const DURATIONS = [ { id: 30, label: '30 min' }, @@ -41,6 +41,7 @@ export default function WorkoutBuilderScreen({ navigation }) { const [duration, setDuration] = useState(60); const [regenKey, setRegenKey] = useState(0); const [saving, setSaving] = useState(false); + const [infoModal, setInfoModal] = useState(null); // { title, body } const toggleArr = useCallback((arr, value) => ( arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] @@ -78,13 +79,12 @@ export default function WorkoutBuilderScreen({ navigation }) { description: preview.description, exercises: preview.exercises, }); - Alert.alert( - 'Sauvegardé', - `"${preview.name}" est dans tes séances. Retrouve-la dans la page Séances.`, - [{ text: 'OK' }], - ); + setInfoModal({ + title: 'Sauvegardé', + body: `"${preview.name}" est dans tes séances. Retrouve-la dans la page Séances.`, + }); } catch (e) { - Alert.alert('Erreur', e && e.message ? e.message : 'Sauvegarde impossible'); + setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Sauvegarde impossible' }); } finally { setSaving(false); } @@ -246,6 +246,15 @@ export default function WorkoutBuilderScreen({ navigation }) { Lancer la séance + + setInfoModal(null)} + /> ); } diff --git a/front/src/screens/Workouts/WorkoutListScreen.js b/front/src/screens/Workouts/WorkoutListScreen.js index 3d0bd8f..b7c0b04 100644 --- a/front/src/screens/Workouts/WorkoutListScreen.js +++ b/front/src/screens/Workouts/WorkoutListScreen.js @@ -5,7 +5,6 @@ import { TouchableOpacity, StyleSheet, FlatList, - Alert, Modal, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -19,6 +18,9 @@ import { instantiateSavedWorkout } from '../../services/savedWorkouts.service'; import { useFocusEffect } from '@react-navigation/native'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; +import MultiLobbyModal from '../../components/workouts/MultiLobbyModal'; +import ConfirmModal from '../../components/common/ConfirmModal'; +import InfoModal from '../../components/common/InfoModal'; // Page d'entrée "Séances". // Header : titre + 2 icônes (Mes exercices, Créer un exercice). @@ -88,13 +90,33 @@ function SavedWorkoutCard({ saved, onPress, onLongPress }) { const SKIP_CONFIRM_KEY = '@athly_skip_workout_confirm'; -export default function WorkoutListScreen({ navigation }) { +export default function WorkoutListScreen({ navigation, route }) { const { loadWorkout } = useWorkoutInProgress(); const { items: savedWorkouts, remove: removeSaved } = useSavedWorkouts(); const [confirmItem, setConfirmItem] = useState(null); // { type: 'template'|'saved', data } const [dontAsk, setDontAsk] = useState(false); + // ─── Lancement en Multi (Section VII) ──────────────────────────────────── + const [multiLobbyVisible, setMultiLobbyVisible] = useState(false); + // Non-null uniquement quand on arrive en rejoignant une invitation reçue + // (voir LobbyInviteCheck) — sinon MultiLobbyModal crée un lobby neuf. + const [existingLobbyId, setExistingLobbyId] = useState(null); + + // Invitation acceptée depuis la notification (LobbyInviteCheck → navigate) : + // ouvre directement MultiLobbyModal en mode "rejoindre" au lieu du parcours + // normal (choisir une séance → Lancer en Multi). + useEffect(() => { + const pendingLobbyId = route?.params?.pendingLobbyId; + if (!pendingLobbyId) return; + setExistingLobbyId(pendingLobbyId); + setMultiLobbyVisible(true); + navigation?.setParams({ pendingLobbyId: undefined }); + }, [route?.params?.pendingLobbyId, navigation]); + + const [deleteSavedTarget, setDeleteSavedTarget] = useState(null); + const [errorInfo, setErrorInfo] = useState(null); + // ─── Tutorial ───────────────────────────────────────────────────────────── const { pendingChapterId, activeChapterId, startChapter, registerScrollRef, registerRemeasure } = useTutorial(); const { ref: headerActionsRef, onLayout: onHeaderActionsLayout } = useTutorialTarget('workout_header_actions'); @@ -158,24 +180,42 @@ export default function WorkoutListScreen({ navigation }) { const handleCancelConfirm = useCallback(() => setConfirmItem(null), []); - const onLongPressSaved = useCallback((saved) => { - Alert.alert( - saved.name, - 'Que faire avec cette séance ?', - [ - { text: 'Annuler', style: 'cancel' }, - { - text: 'Supprimer', - style: 'destructive', - onPress: async () => { - try { await removeSaved(saved.id); } catch (e) { - Alert.alert('Erreur', e && e.message ? e.message : 'Suppression impossible'); - } - }, - }, - ], - ); - }, [removeSaved]); + // "Lancer en Multi" — garde confirmItem pour savoir quelle séance + // instancier une fois le lobby actif (voir MultiLobbyModal → onReady). + const handleLaunchMulti = useCallback(() => { + setExistingLobbyId(null); + setMultiLobbyVisible(true); + }, []); + + const handleMultiReady = useCallback((lobbyId) => { + setMultiLobbyVisible(false); + setExistingLobbyId(null); + if (!navigation) return; + // Cas normal : une séance a été choisie avant "Lancer en Multi". Cas + // invitation acceptée (pas de confirmItem, on a rejoint via notification) : + // on instancie un template par défaut — chacun gère ses propres + // séries/poids côté client, la séance en elle-même n'a pas besoin d'être + // identique entre les membres. + const workout = !confirmItem + ? instantiateWorkout(TEMPLATES[0]) + : confirmItem.type === 'template' + ? instantiateWorkout(confirmItem.data) + : instantiateSavedWorkout(confirmItem.data); + if (!workout) return; + loadWorkout(workout); + navigation.navigate('Workout', { workout, lobbyId }); + setConfirmItem(null); + }, [confirmItem, navigation, loadWorkout]); + + const onLongPressSaved = useCallback((saved) => setDeleteSavedTarget(saved), []); + + const confirmDeleteSaved = useCallback(async () => { + const saved = deleteSavedTarget; + setDeleteSavedTarget(null); + try { await removeSaved(saved.id); } catch (e) { + setErrorInfo(e && e.message ? e.message : 'Suppression impossible'); + } + }, [deleteSavedTarget, removeSaved]); const onOpenBuilder = useCallback(() => { if (navigation) navigation.navigate('WorkoutBuilder'); @@ -333,9 +373,40 @@ export default function WorkoutListScreen({ navigation }) { Lancer ! + + + + Lancer en Multi + + + { setMultiLobbyVisible(false); setExistingLobbyId(null); }} + onReady={handleMultiReady} + /> + + setDeleteSavedTarget(null)} + /> + setErrorInfo(null)} + /> ); } @@ -606,4 +677,11 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '800', }, + multiBtn: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + height: 42, borderRadius: 12, marginTop: 10, + borderWidth: 1, borderColor: `${Colors.primary}40`, + backgroundColor: `${Colors.primary}12`, + }, + multiBtnTxt: { color: Colors.primary, fontSize: 13, fontWeight: '700' }, }); diff --git a/front/src/screens/Workouts/WorkoutScreen.js b/front/src/screens/Workouts/WorkoutScreen.js index f698426..c69fe28 100644 --- a/front/src/screens/Workouts/WorkoutScreen.js +++ b/front/src/screens/Workouts/WorkoutScreen.js @@ -5,7 +5,6 @@ import { TouchableOpacity, StyleSheet, ActivityIndicator, - Alert, FlatList, Platform, } from 'react-native'; @@ -30,6 +29,10 @@ import WorkoutRecapModal from '../../components/workouts/WorkoutRecapModal'; import ShortSessionWarningModal from '../../components/workouts/ShortSessionWarningModal'; import QuestToast from '../../components/common/QuestToast'; import ConfirmModal from '../../components/common/ConfirmModal'; +import LobbyMembersBar from '../../components/workouts/LobbyMembersBar'; +import LobbyWaitingOverlay from '../../components/workouts/LobbyWaitingOverlay'; +import MultiLootModal from '../../components/workouts/MultiLootModal'; +import { getLobby, finishLobby } from '../../services/lobby.service'; const DEFAULT_FILTERS = { muscles: [], levels: [], equipment: [] }; @@ -50,10 +53,36 @@ function estimateMinutes(count) { export default function WorkoutScreen({ route, navigation }) { const { state, actions, loadWorkout } = useWorkoutInProgress(); - const { totalXP } = useWorkoutLogs(); + const { totalXP, addBonusXp } = useWorkoutLogs(); const { bypassAnticheat } = useDevSettings(); const [allInOne, setAllInOne] = useState(false); + // ─── Séance en Multi (Section VII) ─────────────────────────────────────── + // Chacun gère ses propres séries/poids côté client (résilience réseau) — + // le lobby ne sert qu'à afficher les bulles de présence et à synchroniser + // la clôture (voir handleTerminate / executeFinalize plus bas). + const lobbyId = route?.params?.lobbyId ?? null; + const [lobby, setLobby] = useState(null); + const [multiWaiting, setMultiWaiting] = useState(false); + const [multiLoot, setMultiLoot] = useState(null); // { memberCount, bonusPercent, bonusXp } + const multiBonusPendingRef = useRef(null); // { bonusPercent, memberCount } — consommé par executeFinalize + const lobbyPollRef = useRef(null); + + // Bulles de présence : poll léger tant que la séance n'est pas terminée. + useEffect(() => { + if (!lobbyId) return undefined; + let cancelled = false; + const poll = async () => { + try { + const res = await getLobby(lobbyId); + if (!cancelled) setLobby(res.lobby); + } catch (_) {} + }; + poll(); + const interval = setInterval(poll, 4000); + return () => { cancelled = true; clearInterval(interval); }; + }, [lobbyId]); + const [isFinalizing, setIsFinalizing] = useState(false); const [elapsed, setElapsed] = useState(0); const timerRef = useRef(null); @@ -77,6 +106,9 @@ export default function WorkoutScreen({ route, navigation }) { const allowExitRef = useRef(false); const pendingNavActionRef = useRef(null); + // Confirmation de suppression d'un exercice (modale custom, pas d'Alert natif) + const [removeConfirm, setRemoveConfirm] = useState(null); // { sourceIndex, name } + // Quest toast queue const [currentToast, setCurrentToast] = useState(null); const toastQueueRef = useRef([]); @@ -211,15 +243,13 @@ export default function WorkoutScreen({ route, navigation }) { }, [actions, sheetMode]); const onRemove = useCallback((sourceIndex, exercise) => { - Alert.alert( - 'Supprimer', - `Retirer "${exercise && exercise.name ? exercise.name : 'cet exercice'}" de la séance ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Supprimer', style: 'destructive', onPress: () => actions.removeExercise(sourceIndex) }, - ], - ); - }, [actions]); + setRemoveConfirm({ sourceIndex, name: exercise?.name || 'cet exercice' }); + }, []); + + const confirmRemove = useCallback(() => { + if (removeConfirm) actions.removeExercise(removeConfirm.sourceIndex); + setRemoveConfirm(null); + }, [removeConfirm, actions]); const onToggleSuperset = useCallback((sourceIndex) => { actions.toggleSupersetWithNext(sourceIndex); @@ -261,6 +291,18 @@ export default function WorkoutScreen({ route, navigation }) { prevTotalXP, }; + // ── Bonus XP Multi (Section VII) ── consommé une seule fois : posé par + // handleTerminate dès que le lobby passe 'completed' (voir plus bas). + const pendingBonus = multiBonusPendingRef.current; + multiBonusPendingRef.current = null; + if (pendingBonus && pendingBonus.bonusPercent > 0) { + const bonusXp = Math.round(builtRecapData.stats.xpEarned * pendingBonus.bonusPercent); + if (bonusXp > 0) { + addBonusXp('Bonus Multi', bonusXp).catch(() => {}); + } + setMultiLoot({ memberCount: pendingBonus.memberCount, bonusPercent: pendingBonus.bonusPercent, bonusXp }); + } + const toastItems = [ ...(result.completedQuests || []).map((q) => ({ label: q.label, isBonus: false })), ...(result.bonusUnlocked ? [{ label: null, isBonus: true }] : []), @@ -286,7 +328,7 @@ export default function WorkoutScreen({ route, navigation }) { } finally { setIsFinalizing(false); } - }, [actions, state.notes, state.id, totalXP, elapsed]); + }, [actions, state.notes, state.id, totalXP, elapsed, addBonusXp]); // ─── TERMINER LA SÉANCE ─────────────────────────────────────────────────── const handleTerminate = useCallback(async () => { @@ -294,7 +336,7 @@ export default function WorkoutScreen({ route, navigation }) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (e) {} - if (isFinalizing || recapVisible) return; + if (isFinalizing || recapVisible || multiWaiting) return; // Anti-cheat 5 min : bloque si < 300s et bypass désactivé if (elapsed < 300 && !bypassAnticheat) { @@ -302,8 +344,52 @@ export default function WorkoutScreen({ route, navigation }) { return; } + // ── Clôture synchrone Multi (Section VII) ── mon statut passe 'finished'. + // Si je suis le dernier, le lobby passe 'completed' immédiatement (pas + // d'attente). Sinon, écran d'attente bloquant jusqu'à ce que tout le + // monde ait fini (polling — pas de websocket dans ce projet). + if (lobbyId) { + setMultiWaiting(true); + try { + const res = await finishLobby(lobbyId); + setLobby(res.lobby); + if (res.completed) { + multiBonusPendingRef.current = { + bonusPercent: res.lobby.xpBonusPercent, + memberCount: res.lobby.memberCount, + }; + setMultiWaiting(false); + executeFinalize(); + } else { + lobbyPollRef.current = setInterval(async () => { + try { + const poll = await getLobby(lobbyId); + setLobby(poll.lobby); + if (poll.lobby.status === 'completed') { + clearInterval(lobbyPollRef.current); + multiBonusPendingRef.current = { + bonusPercent: poll.lobby.xpBonusPercent, + memberCount: poll.lobby.memberCount, + }; + setMultiWaiting(false); + executeFinalize(); + } + } catch (_) {} + }, 3000); + } + } catch (_) { + // Best-effort : un souci réseau sur le lobby ne doit jamais bloquer + // la validation de la propre séance de l'utilisateur. + setMultiWaiting(false); + executeFinalize(); + } + return; + } + executeFinalize(); - }, [isFinalizing, recapVisible, elapsed, bypassAnticheat, executeFinalize]); + }, [isFinalizing, recapVisible, multiWaiting, elapsed, bypassAnticheat, executeFinalize, lobbyId]); + + useEffect(() => () => { if (lobbyPollRef.current) clearInterval(lobbyPollRef.current); }, []); // Valider quand même (0 XP, shortSession) const handleForceFinish = useCallback(() => { @@ -326,9 +412,7 @@ export default function WorkoutScreen({ route, navigation }) { } }, []); - const closeRecap = useCallback(() => { - setRecapVisible(false); - setRecapData(null); + const leaveWorkoutScreen = useCallback(() => { // Reset the workout context so the next session starts clean actions.reset(); if (navigation) { @@ -343,6 +427,20 @@ export default function WorkoutScreen({ route, navigation }) { } }, [navigation, actions]); + const closeRecap = useCallback(() => { + setRecapVisible(false); + setRecapData(null); + // Séance en Multi avec butin en attente : le popup d'équipe (MultiLootModal) + // prend le relai plutôt que de naviguer immédiatement — voir closeMultiLoot. + if (multiLoot) return; + leaveWorkoutScreen(); + }, [multiLoot, leaveWorkoutScreen]); + + const closeMultiLoot = useCallback(() => { + setMultiLoot(null); + leaveWorkoutScreen(); + }, [leaveWorkoutScreen]); + // ── Vue globale : un bloc inline par exercice, pas de navigation ────────── const renderItemAllInOne = ({ item }) => { if (item.type === 'superset') { @@ -442,6 +540,9 @@ export default function WorkoutScreen({ route, navigation }) { + {/* ── Bulles de présence Multi (Section VII) ── */} + + {/* ── Filtres ── */} @@ -555,6 +656,27 @@ export default function WorkoutScreen({ route, navigation }) { onCancel={cancelAbandon} /> + setRemoveConfirm(null)} + /> + + + + + ); } diff --git a/front/src/services/debug.service.js b/front/src/services/debug.service.js index f93a260..4e48af8 100644 --- a/front/src/services/debug.service.js +++ b/front/src/services/debug.service.js @@ -89,3 +89,14 @@ export async function simulateSearchableFriend() { const res = await API.post('/debug/godmode/simulate-searchable-friend'); return res.data; } + +// ─── Vague 3 : Lobby Multi ───────────────────────────────────────────────────── + +// Crée un lobby Multi avec un coéquipier factice (isTestBot) et envoie une +// vraie notification push d'invitation à l'utilisateur connecté — teste le +// parcours complet (popup d'invitation, rejoindre, prêt, séance, bonus de +// groupe) sans second appareil, le bot suivant automatiquement tes statuts. +export async function simulateLobbyInvite() { + const res = await API.post('/debug/godmode/simulate-lobby-invite'); + return res.data; +} diff --git a/front/src/services/lobby.service.js b/front/src/services/lobby.service.js new file mode 100644 index 0000000..fbda3e8 --- /dev/null +++ b/front/src/services/lobby.service.js @@ -0,0 +1,38 @@ +import API from '../api/api'; + +// ─── Lobby Multi (Section VII) ──────────────────────────────────────────────── + +export async function createLobby() { + const res = await API.post('/lobby/create'); + return res.data; +} + +export async function getLobby(lobbyId) { + const res = await API.get(`/lobby/${lobbyId}`); + return res.data; +} + +export async function inviteToLobby(lobbyId, friendId) { + const res = await API.post(`/lobby/${lobbyId}/invite`, { friendId }); + return res.data; +} + +export async function joinLobby(lobbyId) { + const res = await API.post(`/lobby/${lobbyId}/join`); + return res.data; +} + +export async function readyLobby(lobbyId) { + const res = await API.post(`/lobby/${lobbyId}/ready`); + return res.data; +} + +export async function unreadyLobby(lobbyId) { + const res = await API.post(`/lobby/${lobbyId}/unready`); + return res.data; +} + +export async function finishLobby(lobbyId) { + const res = await API.post(`/lobby/${lobbyId}/finish`); + return res.data; +} From 4e488bf86c025c40c87668ec6363a93bae854df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 9 Jul 2026 16:33:24 +0200 Subject: [PATCH 19/31] =?UTF-8?q?feat(prod):=20finitions=20globales=20v2?= =?UTF-8?q?=20-=20google=20sign-in=20front,=20troph=C3=A9es=20multi=20grad?= =?UTF-8?q?u=C3=A9s,=20anti-r=C3=A9p=C3=A9tition=20notifs=20et=20harmonisa?= =?UTF-8?q?tion=20rouge=20sang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/controllers/reward.controller.js | 26 ++++++++ back/controllers/workoutLobby.controller.js | 13 +++- back/models/User.js | 5 ++ back/tests/workoutLobby.test.js | 40 ++++++++++++ front/.env.example | 19 ++++++ front/app.json | 1 + front/package-lock.json | 57 ++++++++++++++++++ front/package.json | 7 ++- front/src/data/profileThemes.js | 6 +- front/src/hooks/useGoogleAuth.js | 49 +++++++++++++++ front/src/screens/Auth/LoginScreen.js | 67 ++++++++++++++++++++- front/src/screens/Profile/SettingsScreen.js | 2 +- front/src/services/auth.service.js | 8 +++ front/src/services/notificationService.js | 17 +++++- 14 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 front/src/hooks/useGoogleAuth.js diff --git a/back/controllers/reward.controller.js b/back/controllers/reward.controller.js index 84a9f38..8a60f65 100644 --- a/back/controllers/reward.controller.js +++ b/back/controllers/reward.controller.js @@ -130,6 +130,20 @@ const ACHIEVEMENT_CATALOG = { category: 'social', hidden: false, }, + MULTI_SESSIONS_5: { + id: 'MULTI_SESSIONS_5', + name: 'Entraînement en Duo', + description: "Vous avez terminé 5 séances en mode Multi.", + category: 'social', + hidden: false, + }, + MULTI_SESSIONS_30: { + id: 'MULTI_SESSIONS_30', + name: "Frères d'Armes", + description: "Vous avez terminé 30 séances en mode Multi.", + category: 'social', + hidden: false, + }, }; // Nombre total de trophées dans le catalogue (utile pour les stats) @@ -256,6 +270,18 @@ async function checkAndUnlockAchievements(userId) { } } + // Trophées gradués sur le cumul de séances Multi terminées + // (totalMultiSessions, incrémenté dans workoutLobby.controller.js → finishLobby). + const MULTI_SESSION_ACHIEVEMENTS = [ + [5, 'MULTI_SESSIONS_5'], + [30, 'MULTI_SESSIONS_30'], + ]; + for (const [threshold, achievementId] of MULTI_SESSION_ACHIEVEMENTS) { + if (!unlockedIds.has(achievementId) && user.totalMultiSessions >= threshold) { + tryUnlock(achievementId); + } + } + if (newlyUnlocked.length > 0) { user.markModified('achievements'); await user.save(); diff --git a/back/controllers/workoutLobby.controller.js b/back/controllers/workoutLobby.controller.js index 5b35836..215f455 100644 --- a/back/controllers/workoutLobby.controller.js +++ b/back/controllers/workoutLobby.controller.js @@ -317,8 +317,9 @@ exports.unreadyLobby = async (req, res, next) => { /** * Passe le statut du membre appelant à 'finished'. Dès que TOUS les membres * ont fini, le lobby passe 'completed', le bonus XP Multi est figé - * (`xpBonusPercent`), et les trophées sociaux Multi sont vérifiés pour - * chaque membre (FIRST_MULTI_SESSION, MULTI_SQUAD_FULL à 5 joueurs). + * (`xpBonusPercent`), le compteur totalMultiSessions de chaque membre est + * incrémenté, et les trophées sociaux Multi sont vérifiés (FIRST_MULTI_SESSION, + * MULTI_SQUAD_FULL à 5 joueurs, MULTI_SESSIONS_5, MULTI_SESSIONS_30). */ exports.finishLobby = async (req, res, next) => { try { @@ -359,6 +360,14 @@ exports.finishLobby = async (req, res, next) => { if (allFinished) { // Best-effort : les trophées ne doivent jamais faire échouer la clôture. try { + // Incrémente le compteur AVANT de vérifier les trophées gradués + // (MULTI_SESSIONS_5 / MULTI_SESSIONS_30), sinon le seuil serait + // évalué sur l'ancienne valeur. + await User.updateMany( + { _id: { $in: lobby.members.map((m) => m.user) } }, + { $inc: { totalMultiSessions: 1 } }, + ); + const results = await Promise.all( lobby.members.map((m) => checkAndUnlockAchievements(m.user.toString())), ); diff --git a/back/models/User.js b/back/models/User.js index dba79ee..33d5f17 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -149,6 +149,11 @@ const UserSchema = new mongoose.Schema( // cosmétique Rouge Sang Unique à 100 coffres). totalChestsOpened: { type: Number, default: 0, min: 0 }, + // Cumul du nombre de séances Multi terminées (lobby passé 'completed', + // voir workoutLobby.controller.js → finishLobby) — condition des trophées + // gradués MULTI_SESSIONS_5 / MULTI_SESSIONS_30 (reward.controller.js). + totalMultiSessions: { type: Number, default: 0, min: 0 }, + // Cosmétiques Uniques définitivement débloqués (réclamés depuis // l'inventaire — voir inventory.controller.js → claimUniqueItem). // Clés libres du catalogue front (BorderPicker.js / profileThemes.js) : diff --git a/back/tests/workoutLobby.test.js b/back/tests/workoutLobby.test.js index 9652abc..7f8acc5 100644 --- a/back/tests/workoutLobby.test.js +++ b/back/tests/workoutLobby.test.js @@ -374,6 +374,46 @@ describe('Lobby Multi — Section VII', () => { expect(res.body.newlyUnlocked).not.toContain('MULTI_SQUAD_FULL'); }); + it('✅ Incrémente totalMultiSessions pour chaque membre à la clôture', async () => { + const lobbyId = await createActiveLobby([alice, bob]); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`); + + const aliceUser = await User.findById(alice.userId); + const bobUser = await User.findById(bob.userId); + expect(aliceUser.totalMultiSessions).toBe(1); + expect(bobUser.totalMultiSessions).toBe(1); + }); + + it('✅ Débloque MULTI_SESSIONS_5 à la 5e séance Multi terminée', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { totalMultiSessions: 4 } }); + + const lobbyId = await createActiveLobby([alice, bob]); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + const res = await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`); + + const aliceUser = await User.findById(alice.userId); + const bobUser = await User.findById(bob.userId); + expect(aliceUser.totalMultiSessions).toBe(5); + expect(aliceUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_5')).toBe(true); + // Bob n'a fait qu'1 séance Multi : pas encore débloqué pour lui + expect(bobUser.totalMultiSessions).toBe(1); + expect(bobUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_5')).toBe(false); + expect(res.body.newlyUnlocked).not.toContain('MULTI_SESSIONS_5'); + }); + + it('✅ Débloque MULTI_SESSIONS_30 à la 30e séance Multi terminée', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { totalMultiSessions: 29 } }); + + const lobbyId = await createActiveLobby([alice, bob]); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`); + + const aliceUser = await User.findById(alice.userId); + expect(aliceUser.totalMultiSessions).toBe(30); + expect(aliceUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_30')).toBe(true); + }); + it("❌ 422 si le lobby n'est pas encore actif (waiting)", async () => { const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); const lobbyId = res.body.lobby._id; diff --git a/front/.env.example b/front/.env.example index 014189b..65d82c1 100644 --- a/front/.env.example +++ b/front/.env.example @@ -19,3 +19,22 @@ TOKEN_KEY=athly_token # ================================ # development | production APP_ENV=development + + +# ================================ +# GOOGLE OAUTH (Section VIII) +# ================================ +# Client IDs OAuth 2.0 créés dans Google Cloud Console (console.cloud.google.com +# → APIs & Services → Identifiants). Un ID par plateforme car chacun a un +# type d'application différent : +# - GOOGLE_EXPO_CLIENT_ID : type "Web", utilisé pour le proxy Expo Go en dev +# - GOOGLE_IOS_CLIENT_ID : type "iOS", bundle ID com.clementin.athly +# - GOOGLE_ANDROID_CLIENT_ID: type "Android", package com.clementin.athly +# - GOOGLE_WEB_CLIENT_ID : type "Web", utilisé pour la version PWA/web +# Tant qu'ils ne sont pas renseignés, le bouton Google reste désactivé côté +# front (voir useGoogleAuth.js) — le backend répond 501 de toute façon sans +# GOOGLE_CLIENT_ID configuré côté serveur (voir back/.env). +GOOGLE_EXPO_CLIENT_ID= +GOOGLE_IOS_CLIENT_ID= +GOOGLE_ANDROID_CLIENT_ID= +GOOGLE_WEB_CLIENT_ID= diff --git a/front/app.json b/front/app.json index 7485a26..bf1268e 100644 --- a/front/app.json +++ b/front/app.json @@ -3,6 +3,7 @@ "name": "Athly", "slug": "athly-app", "version": "1.0.0", + "scheme": "athly", "orientation": "portrait", "icon": "./assets/favicon.png", "userInterfaceStyle": "light", diff --git a/front/package-lock.json b/front/package-lock.json index 71a2e72..c0a28ff 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -17,11 +17,14 @@ "@react-navigation/stack": "^7.8.7", "axios": "^1.16.0", "expo": "~54.0.27", + "expo-auth-session": "~7.0.11", + "expo-crypto": "~15.0.9", "expo-haptics": "~15.0.8", "expo-linear-gradient": "~15.0.8", "expo-notifications": "^0.32.17", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.11", "lottie-react-native": "~7.3.1", "qs": "^6.15.1", "react": "19.1.0", @@ -5787,6 +5790,24 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz", + "integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==", + "license": "MIT", + "dependencies": { + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.9", + "expo-linking": "~8.0.12", + "expo-web-browser": "~15.0.11", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -5801,6 +5822,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "15.0.9", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz", + "integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.23", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", @@ -5855,6 +5888,20 @@ "react-native": "*" } }, + "node_modules/expo-linking": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", + "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", + "license": "MIT", + "dependencies": { + "expo-constants": "~18.0.13", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.26", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz", @@ -5935,6 +5982,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", + "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", diff --git a/front/package.json b/front/package.json index d63c2a8..69cf2ef 100644 --- a/front/package.json +++ b/front/package.json @@ -20,23 +20,26 @@ "@react-navigation/stack": "^7.8.7", "axios": "^1.16.0", "expo": "~54.0.27", + "expo-auth-session": "~7.0.11", + "expo-crypto": "~15.0.9", "expo-haptics": "~15.0.8", "expo-linear-gradient": "~15.0.8", "expo-notifications": "^0.32.17", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.11", "lottie-react-native": "~7.3.1", "qs": "^6.15.1", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", - "react-native-web": "~0.19.13", "react-native-chart-kit": "^6.12.2", "react-native-dotenv": "^3.4.11", "react-native-gesture-handler": "~2.28.0", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "~4.16.0", - "react-native-svg": "15.12.1" + "react-native-svg": "15.12.1", + "react-native-web": "~0.19.13" }, "private": true, "devDependencies": { diff --git a/front/src/data/profileThemes.js b/front/src/data/profileThemes.js index 4b1a492..8b02fa1 100644 --- a/front/src/data/profileThemes.js +++ b/front/src/data/profileThemes.js @@ -2,6 +2,8 @@ // Each theme overrides accentColor, glow, shimmer and background variant. // unlockLevel: minimum level required to pick this theme (God Mode bypasses). +import { Colors } from '../constants/theme'; + export const PROFILE_THEMES = [ { id: 'auto', @@ -135,8 +137,8 @@ export const PROFILE_THEMES = [ unlockLevel: 0, requiresCosmetic: 'THEME_BLOODSANG', requiresChests: 100, // pour le texte de progression avant réclamation - accentColor: '#FF2E4D', - glowColor: 'rgba(163,0,0,0.75)', + accentColor: Colors.uniqueBloodBright, + glowColor: Colors.uniqueBloodGlow, shimmer: true, hasGlow: true, bgVariant: 'blood', diff --git a/front/src/hooks/useGoogleAuth.js b/front/src/hooks/useGoogleAuth.js new file mode 100644 index 0000000..54794e4 --- /dev/null +++ b/front/src/hooks/useGoogleAuth.js @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import * as Google from 'expo-auth-session/providers/google'; +import * as WebBrowser from 'expo-web-browser'; +import { + GOOGLE_EXPO_CLIENT_ID, + GOOGLE_IOS_CLIENT_ID, + GOOGLE_ANDROID_CLIENT_ID, + GOOGLE_WEB_CLIENT_ID, +} from '@env'; + +// Ferme proprement l'onglet du navigateur système ouvert par promptAsync() +// une fois l'auth terminée — sans ça l'onglet reste bloqué en attente sur +// certaines plateformes (voir la doc expo-auth-session). +WebBrowser.maybeCompleteAuthSession(); + +// ─── useGoogleAuth ──────────────────────────────────────────────────────────── +// Connexion Google en un clic (Section VIII) : demande un idToken via le SDK +// Expo Auth Session, à envoyer ensuite à POST /api/auth/google (vérifié côté +// serveur — voir back/services/auth.service.js → googleLogin). +// +// Les Client IDs viennent de .env (voir .env.example pour la marche à suivre +// dans Google Cloud Console). Tant qu'ils ne sont pas renseignés, `isConfigured` +// vaut false et le bouton doit rester désactivé côté écran appelant. + +export function useGoogleAuth() { + const isConfigured = Boolean( + GOOGLE_EXPO_CLIENT_ID || GOOGLE_IOS_CLIENT_ID || GOOGLE_ANDROID_CLIENT_ID || GOOGLE_WEB_CLIENT_ID, + ); + + const [request, response, promptAsync] = Google.useIdTokenAuthRequest({ + clientId: GOOGLE_EXPO_CLIENT_ID || undefined, + iosClientId: GOOGLE_IOS_CLIENT_ID || undefined, + androidClientId: GOOGLE_ANDROID_CLIENT_ID || undefined, + webClientId: GOOGLE_WEB_CLIENT_ID || undefined, + }); + + const idToken = useMemo(() => { + if (!response || response.type !== 'success') return null; + return response.authentication?.idToken || response.params?.id_token || null; + }, [response]); + + return { + isConfigured, + request, + response, + idToken, + promptAsync, + }; +} diff --git a/front/src/screens/Auth/LoginScreen.js b/front/src/screens/Auth/LoginScreen.js index 4aecc0d..0ae90bd 100644 --- a/front/src/screens/Auth/LoginScreen.js +++ b/front/src/screens/Auth/LoginScreen.js @@ -10,8 +10,9 @@ import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import AuthInput from '../../components/inputs/AuthInput'; import NotificationBanner from '../../components/common/NotificationBanner'; -import { login } from '../../services/auth.service'; +import { login, googleLogin } from '../../services/auth.service'; import { useAuth } from '../../context/AuthContext'; +import { useGoogleAuth } from '../../hooks/useGoogleAuth'; const LOGO_ORANGE = require('../../../assets/logo-orange.png'); const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -30,16 +31,53 @@ function FadeLoader() { export default function LoginScreen({ navigation }) { const { signIn } = useAuth(); + const { isConfigured: googleConfigured, idToken, promptAsync } = useGoogleAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(true); const [emailErr, setEmailErr] = useState(''); const [globalErr, setGlobalErr] = useState(''); const [errType, setErrType] = useState('error'); + // Dès que promptAsync() résout avec succès, useGoogleAuth expose l'idToken — + // on le transmet immédiatement au backend pour vérification et connexion. + useEffect(() => { + if (!idToken) return; + (async () => { + try { + setGoogleLoading(true); + setGlobalErr(''); + const res = await googleLogin(idToken); + if (res?.token) { + await signIn(res.token, rememberMe); + } + } catch (error) { + const status = error?.status; + if (status >= 500) { + setErrType('info'); + setGlobalErr('Une erreur est survenue, notre équipe est sur le coup.'); + } else { + setErrType('error'); + setGlobalErr('Connexion Google impossible. Réessaie.'); + } + } finally { + setGoogleLoading(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idToken]); + + const handleGoogleLogin = async () => { + try { await promptAsync(); } catch (_) { + setErrType('error'); + setGlobalErr('Connexion Google impossible. Réessaie.'); + } + }; + const validateEmail = (val = email) => { if (!val) { setEmailErr('Email requis'); return false; } if (!EMAIL_RE.test(val)) { setEmailErr('Email invalide'); return false; } @@ -163,6 +201,24 @@ export default function LoginScreen({ navigation }) { + {googleConfigured && ( + + {googleLoading + ? + : ( + <> + + Continuer avec Google + + )} + + )} + Pas encore de compte ? navigation.navigate('Register')}> @@ -225,6 +281,15 @@ const s = StyleSheet.create({ dividerLine: { flex: 1, height: 1, backgroundColor: Colors.separator }, dividerText: { color: Colors.textMuted, marginHorizontal: 12, fontSize: 12 }, + googleBtn: { + flexDirection: 'row', height: 56, borderRadius: 14, + justifyContent: 'center', alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.14)', + marginBottom: 20, + }, + googleBtnText: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700' }, + switchRow: { flexDirection: 'row', justifyContent: 'center' }, switchLabel: { color: Colors.textMuted, fontSize: 14 }, linkBold: { color: Colors.primary, fontWeight: '700', fontSize: 14 }, diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 3a2c468..b5a1c9e 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -925,7 +925,7 @@ export default function SettingsScreen({ navigation }) { Crée un groupe avec 3 coéquipiers factices, un par statut de Météo des - séances testable (🔥 Prêt / ⚡ Actif / ✅ Validé - le 4e, 💤 En sommeil, + séances testable (Prêt / Actif / Validé - le 4e, En sommeil, s'obtient en ne touchant à aucun des trois). Rejouable sans doublons. diff --git a/front/src/services/auth.service.js b/front/src/services/auth.service.js index a49dbab..2b2cc61 100644 --- a/front/src/services/auth.service.js +++ b/front/src/services/auth.service.js @@ -13,6 +13,14 @@ export async function register(data) { return res.data; } +// Connexion en un clic via Google — idToken obtenu côté client (expo-auth-session), +// vérifié côté serveur (voir back/services/auth.service.js → googleLogin). +export async function googleLogin(idToken) { + const res = await API.post('/auth/google', { idToken }); + if (res.data?.token) await saveToken(res.data.token); + return res.data; +} + export async function logout() { await removeToken(); } diff --git a/front/src/services/notificationService.js b/front/src/services/notificationService.js index b7055ce..df28eb6 100644 --- a/front/src/services/notificationService.js +++ b/front/src/services/notificationService.js @@ -3,7 +3,12 @@ import { Platform } from 'react-native'; import Constants from 'expo-constants'; import AsyncStorage from '@react-native-async-storage/async-storage'; -const DAILY_NOTIF_IDS_KEY = 'athly:notif:daily_ids:v2'; +const DAILY_NOTIF_IDS_KEY = 'athly:notif:daily_ids:v2'; +// Titre du DERNIER rappel quotidien généré (persisté en cache local) — permet +// à pickDailyOccurrence d'éviter une répétition même quand la planification +// est régénérée (ensureDailyRemindersScheduled), sans quoi le jour 1 d'un +// nouveau lot pourrait reproduire le titre du dernier jour du lot précédent. +const LAST_NOTIF_TITLE_KEY = 'athly:notif:last_title:v1'; const CHANNEL_ORANGE_ID = 'streak-orange'; const CHANNEL_VIOLET_ID = 'streak-purple'; const CHANNEL_BIRTHDAY_ID = 'athly-birthday'; @@ -161,9 +166,14 @@ export async function scheduleDailyReminder(hour = 18, minute = 0, count = DAILY // une nouvelle, pour éviter l'accumulation de rappels dupliqués en arrière-plan. await cancelDailyReminder(); + // Anti-répétition inter-lots : reprend le dernier titre généré (même après + // régénération du stock) pour ne jamais enchaîner deux jours identiques. + let lastTitle = null; + try { lastTitle = await AsyncStorage.getItem(LAST_NOTIF_TITLE_KEY); } catch (_) {} + const now = new Date(); const ids = []; - let previous = null; + let previous = lastTitle ? { msg: { title: lastTitle } } : null; for (let dayOffset = 0; dayOffset < count; dayOffset++) { const date = new Date(now); @@ -195,6 +205,9 @@ export async function scheduleDailyReminder(hour = 18, minute = 0, count = DAILY try { await AsyncStorage.setItem(DAILY_NOTIF_IDS_KEY, JSON.stringify({ hour, minute, ids })); + if (previous?.msg?.title) { + await AsyncStorage.setItem(LAST_NOTIF_TITLE_KEY, previous.msg.title); + } } catch (_) {} return ids; From b1fd27e03d89c9b8c15c5bf60218ea83b902dd2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Thu, 9 Jul 2026 17:10:44 +0200 Subject: [PATCH 20/31] feat(auth): integration du bouton google sign-in et configuration des identifiants oauth client --- back/.env.example | 10 ++++ back/config/env.js | 15 +++++- back/services/auth.service.js | 8 +-- back/tests/googleAuth.test.js | 12 ++--- front/src/hooks/useGoogleAuth.js | 73 +++++++++++++++++++++++++-- front/src/screens/Auth/LoginScreen.js | 4 +- front/vercel.json | 7 +++ 7 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 front/vercel.json diff --git a/back/.env.example b/back/.env.example index aa5a73b..f759839 100644 --- a/back/.env.example +++ b/back/.env.example @@ -60,6 +60,16 @@ SMTP_FROM=votre.adresse.verifiee@domaine.com NODE_ENV=development +# ------------------------------ +# 🔑 Google OAuth (Section VIII) +# ------------------------------ +# Liste des Client IDs OAuth 2.0 créés dans Google Cloud Console, séparés par +# des virgules (un par plateforme front : iOS, Android, Web, Expo Go — voir +# front/.env.example). Vide → POST /api/auth/google répond 501. +# Exemple : GOOGLE_CLIENT_IDS=xxxx-ios.apps.googleusercontent.com,xxxx-android.apps.googleusercontent.com,xxxx-web.apps.googleusercontent.com +GOOGLE_CLIENT_IDS= + + # ------------------------------ # 🌐 CORS (production) # ------------------------------ diff --git a/back/config/env.js b/back/config/env.js index aaf5015..7c69bf6 100644 --- a/back/config/env.js +++ b/back/config/env.js @@ -18,9 +18,20 @@ const config = { // ── Google OAuth (Section VIII) ───────────────────────────────────────────── // Non requis pour démarrer le serveur (contrairement à mongoUri/jwtSecret) : - // tant que la variable n'est pas définie, /api/auth/google répond 501 + // tant que GOOGLE_CLIENT_IDS est vide, /api/auth/google répond 501 // "non configuré" au lieu de planter tout le serveur au démarrage. - googleClientId: process.env.GOOGLE_CLIENT_ID || null, + // + // Liste (pas un seul ID) : le front obtient un idToken via l'un de PLUSIEURS + // Client IDs selon la plateforme (iOS/Android/Web/Expo Go — voir + // front/.env.example), et le claim `aud` du token contient EXACTEMENT celui + // qui l'a émis. verifyIdToken doit donc accepter cette liste complète comme + // audience valide, sinon la connexion échouerait silencieusement sur toutes + // les plateformes sauf une. GOOGLE_CLIENT_ID (singulier) reste supporté pour + // compat ascendante si un seul ID est configuré. + googleClientIds: (process.env.GOOGLE_CLIENT_IDS || process.env.GOOGLE_CLIENT_ID || "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean), }; if (!config.mongoUri) { diff --git a/back/services/auth.service.js b/back/services/auth.service.js index f07d72c..f08c621 100644 --- a/back/services/auth.service.js +++ b/back/services/auth.service.js @@ -214,7 +214,7 @@ class AuthService { * mot de passe aléatoire jamais utilisable pour se connecter autrement). */ async googleLogin(idToken) { - if (!config.googleClientId) { + if (!config.googleClientIds.length) { throw httpError("Connexion Google non configurée sur ce serveur.", 501, "GOOGLE_OAUTH_NOT_CONFIGURED"); } if (!idToken) { @@ -222,11 +222,13 @@ class AuthService { } const { OAuth2Client } = require("google-auth-library"); - const client = new OAuth2Client(config.googleClientId); + const client = new OAuth2Client(); let payload; try { - const ticket = await client.verifyIdToken({ idToken, audience: config.googleClientId }); + // audience accepte un tableau : le token peut avoir été émis pour + // n'importe lequel des Client IDs configurés (iOS/Android/Web/Expo). + const ticket = await client.verifyIdToken({ idToken, audience: config.googleClientIds }); payload = ticket.getPayload(); } catch (_err) { throw httpError("Token Google invalide.", 401, "GOOGLE_TOKEN_INVALID"); diff --git a/back/tests/googleAuth.test.js b/back/tests/googleAuth.test.js index b095838..308946d 100644 --- a/back/tests/googleAuth.test.js +++ b/back/tests/googleAuth.test.js @@ -24,12 +24,12 @@ describe('POST /api/auth/google — connexion Google OAuth (Section VIII)', () = if (mongoose.connection.readyState === 0) { await mongoose.connect(process.env.MONGO_URI); } - originalClientId = config.googleClientId; - config.googleClientId = 'test-client-id.apps.googleusercontent.com'; + originalClientId = config.googleClientIds; + config.googleClientIds = ['test-client-id.apps.googleusercontent.com']; }); afterAll(async () => { - config.googleClientId = originalClientId; + config.googleClientIds = originalClientId; await User.deleteMany({}); await mongoose.connection.close(); }); @@ -101,10 +101,10 @@ describe('POST /api/auth/google — connexion Google OAuth (Section VIII)', () = expect(res.statusCode).toBe(400); }); - it("❌ 501 si Google OAuth n'est pas configuré (pas de GOOGLE_CLIENT_ID)", async () => { - config.googleClientId = null; + it("❌ 501 si Google OAuth n'est pas configuré (pas de GOOGLE_CLIENT_IDS)", async () => { + config.googleClientIds = []; const res = await request(app).post('/api/auth/google').send({ idToken: 'whatever' }); expect(res.statusCode).toBe(501); - config.googleClientId = 'test-client-id.apps.googleusercontent.com'; + config.googleClientIds = ['test-client-id.apps.googleusercontent.com']; }); }); diff --git a/front/src/hooks/useGoogleAuth.js b/front/src/hooks/useGoogleAuth.js index 54794e4..5b19d2e 100644 --- a/front/src/hooks/useGoogleAuth.js +++ b/front/src/hooks/useGoogleAuth.js @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Platform } from 'react-native'; import * as Google from 'expo-auth-session/providers/google'; import * as WebBrowser from 'expo-web-browser'; import { @@ -13,6 +14,20 @@ import { // certaines plateformes (voir la doc expo-auth-session). WebBrowser.maybeCompleteAuthSession(); +// Clé sessionStorage utilisée pour vérifier l'état retour d'un flux en plein +// écran (voir plus bas — PWA installée). sessionStorage survit à une +// navigation complète de la page (contrairement à un state React), ce qui +// est indispensable ici puisque tout le contexte JS est détruit pendant que +// Google Sign-In s'affiche. +const PENDING_STATE_KEY = 'athly:google:pending_state:v1'; + +function isStandaloneDisplayMode() { + if (Platform.OS !== 'web' || typeof window === 'undefined') return false; + const mediaStandalone = window.matchMedia?.('(display-mode: standalone)').matches; + const iosStandalone = window.navigator?.standalone === true; // Safari iOS n'a pas display-mode + return Boolean(mediaStandalone || iosStandalone); +} + // ─── useGoogleAuth ──────────────────────────────────────────────────────────── // Connexion Google en un clic (Section VIII) : demande un idToken via le SDK // Expo Auth Session, à envoyer ensuite à POST /api/auth/google (vérifié côté @@ -21,6 +36,17 @@ WebBrowser.maybeCompleteAuthSession(); // Les Client IDs viennent de .env (voir .env.example pour la marche à suivre // dans Google Cloud Console). Tant qu'ils ne sont pas renseignés, `isConfigured` // vaut false et le bouton doit rester désactivé côté écran appelant. +// +// ── Cas particulier PWA installée (Section IX) ──────────────────────────── +// expo-auth-session ouvre une popup (window.open + postMessage) pour récupérer +// le résultat côté web. Ce mécanisme est fragile depuis une PWA en mode +// standalone (surtout iOS Safari "Ajouter à l'écran d'accueil") : window.open +// y fait souvent sortir l'utilisateur de l'app installée sans jamais pouvoir +// relayer le message à la fenêtre d'origine, et la connexion reste bloquée. +// Dans ce cas précis, on bascule sur une redirection plein écran (navigue +// loin de l'app, puis revient dessus) : l'idToken est alors récupéré au +// prochain montage du hook via le hash de l'URL de retour, pas via la Promise +// de promptAsync (qui ne peut pas survivre au rechargement complet de la page). export function useGoogleAuth() { const isConfigured = Boolean( @@ -34,16 +60,57 @@ export function useGoogleAuth() { webClientId: GOOGLE_WEB_CLIENT_ID || undefined, }); + const [redirectIdToken, setRedirectIdToken] = useState(null); + + // Récupère un éventuel idToken laissé dans le hash de l'URL par le flux de + // redirection plein écran (PWA standalone) — vérifié une seule fois au + // montage, jamais pendant le flux popup normal (le hash n'existe alors pas + // sur CETTE page, seulement sur la popup). + useEffect(() => { + if (Platform.OS !== 'web' || typeof window === 'undefined') return; + if (!window.location.hash || !window.location.hash.includes('id_token=')) return; + + let expectedState = null; + try { expectedState = window.sessionStorage.getItem(PENDING_STATE_KEY); } catch (_) {} + + const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const returnedIdToken = hashParams.get('id_token'); + const returnedState = hashParams.get('state'); + + // Nettoie systématiquement l'URL — un idToken ne doit jamais rester + // visible dans la barre d'adresse ou l'historique du navigateur. + window.history.replaceState(null, '', window.location.pathname + window.location.search); + try { window.sessionStorage.removeItem(PENDING_STATE_KEY); } catch (_) {} + + // Anti-CSRF : le state retourné doit correspondre exactement à celui + // généré avant la redirection (voir handlePromptAsync ci-dessous). + if (returnedIdToken && expectedState && returnedState === expectedState) { + setRedirectIdToken(returnedIdToken); + } + }, []); + const idToken = useMemo(() => { + if (redirectIdToken) return redirectIdToken; if (!response || response.type !== 'success') return null; return response.authentication?.idToken || response.params?.id_token || null; - }, [response]); + }, [response, redirectIdToken]); + + const handlePromptAsync = useCallback(async (...args) => { + if (isStandaloneDisplayMode() && request?.url && request?.state) { + try { window.sessionStorage.setItem(PENDING_STATE_KEY, request.state); } catch (_) {} + window.location.assign(request.url); + // La navigation détruit ce contexte JS avant toute résolution possible — + // le résultat sera repris par l'effet ci-dessus au prochain montage. + return { type: 'opened' }; + } + return promptAsync(...args); + }, [request, promptAsync]); return { isConfigured, request, response, idToken, - promptAsync, + promptAsync: handlePromptAsync, }; } diff --git a/front/src/screens/Auth/LoginScreen.js b/front/src/screens/Auth/LoginScreen.js index 0ae90bd..f535d91 100644 --- a/front/src/screens/Auth/LoginScreen.js +++ b/front/src/screens/Auth/LoginScreen.js @@ -31,7 +31,7 @@ function FadeLoader() { export default function LoginScreen({ navigation }) { const { signIn } = useAuth(); - const { isConfigured: googleConfigured, idToken, promptAsync } = useGoogleAuth(); + const { isConfigured: googleConfigured, request: googleRequest, idToken, promptAsync } = useGoogleAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -205,7 +205,7 @@ export default function LoginScreen({ navigation }) { {googleLoading diff --git a/front/vercel.json b/front/vercel.json new file mode 100644 index 0000000..e4637d2 --- /dev/null +++ b/front/vercel.json @@ -0,0 +1,7 @@ +{ + "buildCommand": "npm run build:web", + "outputDirectory": "dist", + "rewrites": [ + { "source": "/(.*)", "destination": "/index.html" } + ] +} From 4fd549d195ac81a4a4186872a685456cada7d1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 10 Jul 2026 11:07:58 +0200 Subject: [PATCH 21/31] feat(rpg): implementation complete du systeme de titres developpeur, selecteur avec preview et retours haptiques premium --- back/app.js | 2 + back/controllers/debug.controller.js | 37 ++ back/controllers/friend.controller.js | 11 +- back/controllers/groupStreak.controller.js | 57 ++- back/controllers/inventory.controller.js | 5 + back/controllers/reward.controller.js | 28 ++ back/controllers/title.controller.js | 325 ++++++++++++++ back/controllers/workoutLobby.controller.js | 12 +- back/data/titleCatalog.js | 156 +++++++ back/models/StreakGroup.js | 7 + back/models/User.js | 21 + back/routes/debug.routes.js | 3 + back/routes/profile.routes.js | 14 + back/services/auth.service.js | 4 + back/services/workout.service.js | 27 ++ back/tests/debug.test.js | 39 ++ back/tests/titles.test.js | 398 ++++++++++++++++++ front/src/components/cards/SetRow.js | 9 +- front/src/components/profile/HeroLevelCard.js | 33 ++ .../components/profile/TitlePickerModal.js | 249 +++++++++++ front/src/screens/Auth/RegisterScreen.js | 2 + front/src/screens/Profile/InventoryScreen.js | 3 + front/src/screens/Profile/ProfileScreen.js | 40 +- front/src/screens/Profile/SettingsScreen.js | 49 ++- .../src/screens/Social/FriendProfileScreen.js | 3 + front/src/screens/Workouts/WorkoutScreen.js | 8 +- front/src/services/debug.service.js | 9 + front/src/services/haptics.service.js | 43 ++ front/src/services/title.service.js | 14 + 29 files changed, 1589 insertions(+), 19 deletions(-) create mode 100644 back/controllers/title.controller.js create mode 100644 back/data/titleCatalog.js create mode 100644 back/routes/profile.routes.js create mode 100644 back/tests/titles.test.js create mode 100644 front/src/components/profile/TitlePickerModal.js create mode 100644 front/src/services/haptics.service.js create mode 100644 front/src/services/title.service.js diff --git a/back/app.js b/back/app.js index 776ed28..5a72ca0 100644 --- a/back/app.js +++ b/back/app.js @@ -18,6 +18,7 @@ const debugRoutes = require("./routes/debug.routes"); const activityRoutes = require("./routes/activity.routes"); const weightRoutes = require("./routes/weight.routes"); const workoutLobbyRoutes = require("./routes/workoutLobby.routes"); +const profileRoutes = require("./routes/profile.routes"); // --- Importation des middlewares --- const errorMiddleware = require("./middleware/error.middleware"); @@ -100,6 +101,7 @@ app.use("/api/debug", debugRoutes); app.use("/api/activity", activityRoutes); app.use("/api/weight", weightRoutes); app.use("/api/lobby", workoutLobbyRoutes); +app.use("/api/profile", profileRoutes); // --- Gestion des erreurs --- // Route 404 diff --git a/back/controllers/debug.controller.js b/back/controllers/debug.controller.js index ea77333..7e2f007 100644 --- a/back/controllers/debug.controller.js +++ b/back/controllers/debug.controller.js @@ -10,6 +10,7 @@ const WorkoutLobby = require('../models/WorkoutLobby'); const { xpForLevel, getRankForLevel } = require('../utils/levelHelpers'); const { addItemAtomic, addUniqueItemOnce } = require('../services/inventory.service'); const { checkAndUnlockAchievements } = require('./reward.controller'); +const { TITLE_CATALOG } = require('../data/titleCatalog'); const { recordActivityEvent } = require('../services/activity.service'); const { sendPushToUser } = require('../services/push.service'); const { SHAKE_TROLL_MESSAGES } = require('../data/shakeMessages'); @@ -156,6 +157,42 @@ exports.giveAllItems = async (req, res, next) => { } }; +// ───────────────────────────────────────────────────────────────────────────── +// giveAllTitles POST /api/debug/godmode/give-all-titles +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Outil de test : débloque directement TOUS les titres du catalogue (Section X) + * dans unlockedTitles, sans passer par leurs conditions réelles — permet de + * tester le sélecteur de titres et les cosmétiques associés sans enchaîner + * des dizaines d'actions de jeu. Idempotent ($addToSet via Set union). + */ +exports.giveAllTitles = async (req, res, next) => { + try { + const allTitleIds = Object.keys(TITLE_CATALOG); + + const user = await User.findById(req.user.id); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + const unlockedSet = new Set(user.unlockedTitles); + allTitleIds.forEach((id) => unlockedSet.add(id)); + user.unlockedTitles = [...unlockedSet]; + await user.save(); + + // TITLE_FIRST / TITLE_COLLECTOR_5 doivent aussi se débloquer ici. + const newlyUnlocked = await checkAndUnlockAchievements(req.user.id); + + return res.status(200).json({ + success: true, + message: `${allTitleIds.length} titre(s) débloqué(s).`, + unlockedTitles: user.unlockedTitles, + newlyUnlocked, + }); + } catch (err) { + next(err); + } +}; + // ───────────────────────────────────────────────────────────────────────────── // simulateChestsOpened POST /api/debug/godmode/simulate-chests-opened // ───────────────────────────────────────────────────────────────────────────── diff --git a/back/controllers/friend.controller.js b/back/controllers/friend.controller.js index 96920d7..fb39262 100644 --- a/back/controllers/friend.controller.js +++ b/back/controllers/friend.controller.js @@ -498,9 +498,17 @@ exports.getFriendProfile = async (req, res, next) => { } const friend = await User.findById(friendId) - .select('pseudo level rank xp achievements showcasedAchievements showcasedRecords streakGels totalWorkoutMinutes equippedFrame createdAt'); + .select('pseudo level rank xp achievements showcasedAchievements showcasedRecords streakGels totalWorkoutMinutes equippedFrame equippedTitle createdAt'); if (!friend) return next(createError('Utilisateur introuvable.', 404)); + // Résolu côté serveur (label + rareté) pour que le front n'ait pas besoin + // de dupliquer le catalogue des titres juste pour afficher un badge — + // voir data/titleCatalog.js. + const { TITLE_CATALOG } = require('../data/titleCatalog'); + const equippedTitleInfo = friend.equippedTitle && TITLE_CATALOG[friend.equippedTitle] + ? { id: friend.equippedTitle, label: TITLE_CATALOG[friend.equippedTitle].label, rarity: TITLE_CATALOG[friend.equippedTitle].rarity } + : null; + const friendObjectId = new mongoose.Types.ObjectId(friendId); // Records mis en avant par l'ami lui-même (max 6, ordre choisi) — voir @@ -543,6 +551,7 @@ exports.getFriendProfile = async (req, res, next) => { success: true, profile: { user: friend, + equippedTitleInfo, friendshipLevel: friendship.friendshipLevel, friendshipXp: friendship.friendshipXp, stats: { diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index 7665f30..b185c80 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -7,6 +7,7 @@ const User = require('../models/User'); const Workout = require('../models/Workout'); const { addUniqueItemOnce } = require('../services/inventory.service'); const { checkAndUnlockAchievements } = require('./reward.controller'); +const { checkAndUnlockTitles } = require('./title.controller'); const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); const { sendPushToUser } = require('../services/push.service'); const { SHAKE_TROLL_MESSAGES } = require('../data/shakeMessages'); @@ -258,12 +259,27 @@ async function detectAndApplyStreakBreak(group) { } const breakers = []; + const coveredMemberIds = []; for (const memberId of absentMemberIds) { const covered = await User.findOneAndUpdate( { _id: memberId, streakGels: { $gt: 0 } }, { $inc: { streakGels: -1 } }, ); - if (!covered) breakers.push(memberId); + if (covered) { + coveredMemberIds.push(memberId); + } else { + breakers.push(memberId); + } + } + + // Titre STREAK_INSUBMERSIBLE ("sauver sa streak 3 fois de suite avec un Gel + // de Streak") : incrémente pour chaque sauvetage, remis à 0 pour un membre + // dès qu'il devient lui-même breaker (rupture non couverte, voir plus bas). + if (coveredMemberIds.length > 0) { + await User.updateMany( + { _id: { $in: coveredMemberIds } }, + { $inc: { consecutiveStreakGelSaves: 1 } }, + ); } if (breakers.length === 0) { @@ -271,12 +287,28 @@ async function detectAndApplyStreakBreak(group) { // curseur pour ne pas re-consommer un gel supplémentaire au prochain appel. group.lastValidatedDate = missedDayStart; await group.save(); + try { + await Promise.all(coveredMemberIds.map((id) => checkAndUnlockTitles(id))); + } catch (_) { + // best-effort — ne doit jamais bloquer la détection de rupture. + } return group; } + await User.updateMany( + { _id: { $in: breakers } }, + { $set: { consecutiveStreakGelSaves: 0 } }, + ); + group.currentStreak = 0; group.shameBreakers = breakers; + group.shameBreakersShamedAt = new Date(); await group.save(); + try { + await Promise.all(coveredMemberIds.map((id) => checkAndUnlockTitles(id))); + } catch (_) { + // best-effort + } return group; } @@ -526,10 +558,22 @@ exports.shakeMember = async (req, res, next) => { group.shakes.push({ from: myId, to: memberId, date: new Date() }); await group.save(); + // Titres SHAME_BURNING (>15 secousses au total) / SOCIAL_POKE_STRIKER + // (3 cibles différentes le même jour) — best-effort, ne bloque jamais + // l'envoi de la notification déjà effectué ci-dessus. + let newlyUnlockedTitles = []; + try { + await User.updateOne({ _id: myId }, { $inc: { totalShakesSent: 1 } }); + newlyUnlockedTitles = await checkAndUnlockTitles(myId); + } catch (_) { + // ignore + } + return res.status(200).json({ success: true, message: `Notification envoyée à ${targetPseudo} !`, targetId: memberId, + newlyUnlockedTitles, }); } catch (err) { next(err); @@ -642,6 +686,16 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { await Promise.all(memberIds.map((id) => checkAndUnlockAchievements(id))); } + // Titres GROUP_GUILD_MASTER / SHAME_REPENTANCE — best-effort, ne doit + // jamais faire échouer la validation de streak déjà actée ci-dessus. + let newlyUnlockedTitlesByUser = {}; + try { + const titleResults = await Promise.all(memberIds.map((id) => checkAndUnlockTitles(id))); + memberIds.forEach((id, i) => { newlyUnlockedTitlesByUser[id] = titleResults[i]; }); + } catch (_) { + // ignore + } + return res.status(200).json({ success: true, allValidated: true, @@ -651,6 +705,7 @@ exports.checkAndUpdateGroupStreaks = async (req, res, next) => { xpUpdates, groupBonus: { ...xpBonus, memberCount: memberIds.length }, bloodSangUnlocked, + newlyUnlockedTitles: newlyUnlockedTitlesByUser[myId] ?? [], }); } catch (err) { next(err); diff --git a/back/controllers/inventory.controller.js b/back/controllers/inventory.controller.js index 72ffb8b..d370449 100644 --- a/back/controllers/inventory.controller.js +++ b/back/controllers/inventory.controller.js @@ -5,6 +5,7 @@ const { drawChestItem } = require('../services/chest.service'); const { consumeItemAtomic, addItemAtomic, addUniqueItemOnce, purgeEmptyEntries } = require('../services/inventory.service'); const { levelFromXP, getRankForLevel } = require('../utils/levelHelpers'); const { checkAndUnlockAchievements } = require('./reward.controller'); +const { checkAndUnlockTitles } = require('./title.controller'); const { recordActivityEvent } = require('../services/activity.service'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -171,6 +172,7 @@ exports.openChest = async (req, res, next) => { const finalUser = await purgeEmptyEntries(req.user.id); const newlyUnlocked = await checkAndUnlockAchievements(req.user.id); + const newlyUnlockedTitles = await checkAndUnlockTitles(req.user.id).catch(() => []); return res.status(200).json({ success: true, @@ -180,6 +182,7 @@ exports.openChest = async (req, res, next) => { totalChestsOpened: afterCount ? afterCount.totalChestsOpened : null, themeUnlockGranted, newlyUnlocked, + newlyUnlockedTitles, }); } catch (err) { next(err); @@ -233,6 +236,7 @@ exports.claimUniqueItem = async (req, res, next) => { ).select('unlockedCosmetics equippedFrame inventory'); const finalUser = await purgeEmptyEntries(req.user.id); + const newlyUnlockedTitles = await checkAndUnlockTitles(req.user.id).catch(() => []); return res.status(200).json({ success: true, @@ -241,6 +245,7 @@ exports.claimUniqueItem = async (req, res, next) => { equippedFrame: updated.equippedFrame, unlockedCosmetics: updated.unlockedCosmetics, inventory: finalUser.inventory, + newlyUnlockedTitles, }); } catch (err) { next(err); diff --git a/back/controllers/reward.controller.js b/back/controllers/reward.controller.js index 8a60f65..48df2cc 100644 --- a/back/controllers/reward.controller.js +++ b/back/controllers/reward.controller.js @@ -144,6 +144,22 @@ const ACHIEVEMENT_CATALOG = { category: 'social', hidden: false, }, + + // ── Titres (Section X) ────────────────────────────────────────────────────── + TITLE_FIRST: { + id: 'TITLE_FIRST', + name: 'Nouvelle Identité', + description: "Vous avez débloqué votre premier titre.", + category: 'social', + hidden: false, + }, + TITLE_COLLECTOR_5: { + id: 'TITLE_COLLECTOR_5', + name: 'Homme aux Mille Visages', + description: "Vous avez débloqué 5 titres différents.", + category: 'social', + hidden: false, + }, }; // Nombre total de trophées dans le catalogue (utile pour les stats) @@ -282,6 +298,18 @@ async function checkAndUnlockAchievements(userId) { } } + // Trophées gradués sur le nombre de titres débloqués (Section X). + const TITLE_COUNT_ACHIEVEMENTS = [ + [1, 'TITLE_FIRST'], + [5, 'TITLE_COLLECTOR_5'], + ]; + const unlockedTitleCount = (user.unlockedTitles || []).length; + for (const [threshold, achievementId] of TITLE_COUNT_ACHIEVEMENTS) { + if (!unlockedIds.has(achievementId) && unlockedTitleCount >= threshold) { + tryUnlock(achievementId); + } + } + if (newlyUnlocked.length > 0) { user.markModified('achievements'); await user.save(); diff --git a/back/controllers/title.controller.js b/back/controllers/title.controller.js new file mode 100644 index 0000000..f02adc3 --- /dev/null +++ b/back/controllers/title.controller.js @@ -0,0 +1,325 @@ +'use strict'; + +// ─── Titres déblocables (Section X) ──────────────────────────────────────────── +// checkAndUnlockTitles est appelée depuis les points d'action existants +// (clôture de séance, ouverture de coffre, parrainage, bouton Secouer, +// validation/rupture de streak, clôture de Lobby Multi) — même philosophie que +// checkAndUnlockAchievements dans reward.controller.js, mais pour les titres. + +const mongoose = require('mongoose'); +const User = require('../models/User'); +const StreakGroup = require('../models/StreakGroup'); +const Workout = require('../models/Workout'); +const WorkoutLobby = require('../models/WorkoutLobby'); +const ExerciseRecord = require('../models/ExerciseRecord'); +const { TITLE_CATALOG } = require('../data/titleCatalog'); +const { sendPushToUser } = require('../services/push.service'); + +function createError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +// Mêmes 7 exercices que front/src/data/majorExercises.js — condition +// PR_HEAVY_100 ("record sur un exo majeur"), dupliqué volontairement ici +// comme le reste des tables de référence de ce contrôleur (pas de dépendance +// backend → code front). +const MAJOR_EXERCISE_NAMES = [ + 'Développé couché', 'Squat', 'Soulevé de terre', 'Tractions', + 'Développé militaire', 'Rowing barre', 'Curl barre', +]; + +const GUILD_MASTER_MIN_MEMBERS = 5; +const GUILD_MASTER_MIN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const SHAME_REPENTANCE_WINDOW_MS = 2 * 60 * 60 * 1000; +const STREAK_GEL_SAVE_TARGET = 3; +const IRON_BREAKER_SETS_TARGET = 100; +const HOARDER_ITEM_TARGET = 10; +const SHAKE_BURNING_TARGET = 15; +const POKE_STRIKER_DISTINCT_TARGET = 3; +const LOBBY_INSTIGATOR_TARGET = 20; + +// ── Conditions nécessitant une requête dédiée (pas un simple champ User) ────── + +async function hasPrHeavy100(userId) { + return ExerciseRecord.exists({ + user: userId, + exerciceNom: { $in: MAJOR_EXERCISE_NAMES }, + series: { $elemMatch: { poids: { $gt: 100 } } }, + }); +} + +async function hasNightOwlWorkout(userId) { + // $hour utilise le fuseau serveur (UTC sur Render) — cohérent avec le reste + // du backend, qui ne gère pas de fuseau par utilisateur. + // Cast explicite : un pipeline $match d'agrégation ne caste PAS une string + // en ObjectId comme le ferait .find()/.exists() — sans ça, 0 résultat. + const rows = await Workout.aggregate([ + { $match: { user: new mongoose.Types.ObjectId(userId) } }, + { $addFields: { hour: { $hour: '$createdAt' } } }, + { $match: { hour: { $gte: 0, $lt: 5 } } }, + { $limit: 1 }, + ]); + return rows.length > 0; +} + +// "S'entraîner seul pendant qu'un de ses groupes est actif" : une séance +// terminée par CET utilisateur (via workout.service.js finalize/complete — +// jamais un Lobby Multi, qui n'écrit pas de Workout backend) recouvre dans le +// temps une séance 'in_progress' d'un AUTRE membre d'un de ses groupes — +// même notion d' "actif" que la Météo des séances (weatherStatus). +async function hasSoloShadowWork(userId, finishedWorkout) { + if (!finishedWorkout) return false; + const groups = await StreakGroup.find({ members: userId }).select('members'); + if (groups.length === 0) return false; + + const otherMemberIds = [...new Set( + groups.flatMap((g) => g.members.map(String)).filter((id) => id !== String(userId)), + )]; + if (otherMemberIds.length === 0) return false; + + // 'in_progress' est par nature une séance encore en cours AU MOMENT de la + // requête — pas besoin de recouper des fenêtres temporelles, c'est la même + // notion d' "actif" que la Météo des séances (weatherStatus). + return Workout.exists({ + user: { $in: otherMemberIds }, + status: 'in_progress', + }); +} + +async function countLobbyInstigator(userId) { + return WorkoutLobby.countDocuments({ creatorId: userId, status: 'completed' }); +} + +async function findGuildMasterGroup(userId) { + const groups = await StreakGroup.find({ members: userId }).select('members createdAt'); + return groups.find((g) => + g.members.length >= GUILD_MASTER_MIN_MEMBERS && + g.members[0]?.toString() === String(userId) && + (Date.now() - new Date(g.createdAt).getTime()) >= GUILD_MASTER_MIN_DAYS_MS, + ) || null; +} + +// "Secouer 3 membres différents le même jour" : agrège les secousses ENVOYÉES +// par l'utilisateur, tous groupes confondus, sur la journée civile en cours. +async function countDistinctShakeTargetsToday(userId) { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const tomorrow = new Date(todayStart); + tomorrow.setDate(tomorrow.getDate() + 1); + + const groups = await StreakGroup.find({ members: userId }).select('shakes'); + const targets = new Set(); + for (const group of groups) { + for (const shake of group.shakes) { + if ( + shake.from.toString() === String(userId) && + shake.date >= todayStart && shake.date < tomorrow + ) { + targets.add(shake.to.toString()); + } + } + } + return targets.size; +} + +// "Sortir du Hall of Shame en validant sa séance moins de 2h après y avoir +// été affiché" : l'utilisateur doit être (ou avoir été) dans shameBreakers +// d'un groupe, et avoir une séance validée dans les 2h suivant shameBreakersShamedAt. +async function hasShameRepentance(userId) { + const groups = await StreakGroup.find({ + members: userId, + shameBreakersShamedAt: { $ne: null }, + }).select('shameBreakersShamedAt shameBreakers'); + + for (const group of groups) { + // On considère aussi les anciens breakers (le tableau est vidé à la + // streak suivante) : shameBreakersShamedAt seul suffit à borner la + // fenêtre de 2h, peu importe que shameBreakers ait depuis été vidé. + const windowEnd = new Date(group.shameBreakersShamedAt.getTime() + SHAME_REPENTANCE_WINDOW_MS); + const redeemed = await Workout.exists({ + user: userId, + status: { $in: ['finished', 'completed'] }, + completedAt: { $gte: group.shameBreakersShamedAt, $lte: windowEnd }, + }); + if (redeemed) return true; + } + return false; +} + +function inventoryItemCount(user) { + return (user.inventory || []).reduce((sum, item) => sum + (item.quantity || 0), 0); +} + +function hasRarityItem(user, rarity) { + return (user.inventory || []).some((i) => i.rarity === rarity && i.quantity > 0); +} + +// Un cosmétique Unique réclamé (claimUniqueItem) est CONSOMMÉ de l'inventaire +// et déplacé vers unlockedCosmetics (permanent) — hasRarityItem seul ne +// détecterait donc plus rien après réclamation. On vérifie les deux sources. +function hasEverOwnedUnique(user) { + return hasRarityItem(user, 'unique') || (user.unlockedCosmetics || []).length > 0; +} + +// ── checkAndUnlockTitles ──────────────────────────────────────────────────── +/** + * Vérifie toutes les conditions de titres pour `userId` et débloque celles + * qui sont remplies mais pas encore acquises. Best-effort : appelée depuis de + * nombreux points d'action existants, ne doit jamais faire échouer l'action + * qui l'a déclenchée (les appelants doivent l'entourer d'un try/catch — voir + * les hooks dans workoutLobby/inventory/groupStreak/workout controllers). + * + * @param {string} userId + * @param {object} [context] — infos additionnelles selon le point d'appel : + * { finishedWorkout } pour SOLO_SHADOW_WORK (séance backend qui vient de se terminer). + * @returns {string[]} IDs des titres nouvellement débloqués. + */ +async function checkAndUnlockTitles(userId, context = {}) { + const user = await User.findById(userId); + if (!user) return []; + + const unlockedIds = new Set(user.unlockedTitles); + const newlyUnlocked = []; + + function tryUnlock(titleId) { + if (unlockedIds.has(titleId)) return; + user.unlockedTitles.push(titleId); + unlockedIds.add(titleId); + newlyUnlocked.push(titleId); + } + + if (user.level >= 10) tryUnlock('PERFORM_LEVEL_10'); + if (user.level >= 50) tryUnlock('PERFORM_LEVEL_50'); + if (user.referredBy) tryUnlock('REFERRAL_EARLY'); + if (hasRarityItem(user, 'legendary')) tryUnlock('LOOT_LEGENDARY'); + if (hasEverOwnedUnique(user)) tryUnlock('LOOT_BLOOD_UNIQUE'); + if (inventoryItemCount(user) >= HOARDER_ITEM_TARGET) tryUnlock('INVENTORY_HOARDER'); + if (user.totalMultiSessions >= 30) tryUnlock('MULTI_SESSIONS_30_TITLE'); + if (user.totalShakesSent >= SHAKE_BURNING_TARGET) tryUnlock('SHAME_BURNING'); + if (user.totalSetsCompleted >= IRON_BREAKER_SETS_TARGET) tryUnlock('WORKOUT_IRON_BREAKER'); + if (user.consecutiveStreakGelSaves >= STREAK_GEL_SAVE_TARGET) tryUnlock('STREAK_INSUBMERSIBLE'); + + if (!unlockedIds.has('PR_HEAVY_100') && await hasPrHeavy100(userId)) tryUnlock('PR_HEAVY_100'); + if (!unlockedIds.has('WORKOUT_NIGHT_OWL') && await hasNightOwlWorkout(userId)) tryUnlock('WORKOUT_NIGHT_OWL'); + if (!unlockedIds.has('LOBBY_INSTIGATOR') && await countLobbyInstigator(userId) >= LOBBY_INSTIGATOR_TARGET) tryUnlock('LOBBY_INSTIGATOR'); + if (!unlockedIds.has('GROUP_GUILD_MASTER') && await findGuildMasterGroup(userId)) tryUnlock('GROUP_GUILD_MASTER'); + if (!unlockedIds.has('SOCIAL_POKE_STRIKER') && await countDistinctShakeTargetsToday(userId) >= POKE_STRIKER_DISTINCT_TARGET) tryUnlock('SOCIAL_POKE_STRIKER'); + if (!unlockedIds.has('SHAME_REPENTANCE') && await hasShameRepentance(userId)) tryUnlock('SHAME_REPENTANCE'); + if (!unlockedIds.has('SOLO_SHADOW_WORK') && await hasSoloShadowWork(userId, context.finishedWorkout)) tryUnlock('SOLO_SHADOW_WORK'); + + if (newlyUnlocked.length > 0) { + await user.save(); + for (const titleId of newlyUnlocked) { + const meta = TITLE_CATALOG[titleId]; + if (!meta) continue; + sendPushToUser(userId, { + title: 'Nouveau titre débloqué !', + body: `« ${meta.label} » est maintenant disponible dans tes titres.`, + data: { type: 'title_unlocked', titleId }, + }).catch(() => {}); + } + + // Trophées TITLE_FIRST / TITLE_COLLECTOR_5 (require tardif : évite le + // cycle reward.controller ↔ title.controller au chargement des modules). + try { + const { checkAndUnlockAchievements } = require('./reward.controller'); + await checkAndUnlockAchievements(userId); + } catch (_) { + // best-effort — ne doit jamais faire échouer le déblocage de titre. + } + } + + return newlyUnlocked; +} + +// ── Progression (pour l'écran de sélection) ──────────────────────────────── +// Renvoie, pour les titres à seuil numérique, { current, target } — les +// titres purement événementiels (parrainage, loot, night owl…) n'ont pas de +// barre de progression significative côté front (juste verrouillé/débloqué). +async function computeProgress(user) { + const progress = {}; + progress.PERFORM_LEVEL_10 = { current: Math.min(user.level, 10), target: 10 }; + progress.PERFORM_LEVEL_50 = { current: Math.min(user.level, 50), target: 50 }; + progress.INVENTORY_HOARDER = { current: Math.min(inventoryItemCount(user), HOARDER_ITEM_TARGET), target: HOARDER_ITEM_TARGET }; + progress.MULTI_SESSIONS_30_TITLE = { current: Math.min(user.totalMultiSessions, 30), target: 30 }; + progress.SHAME_BURNING = { current: Math.min(user.totalShakesSent, SHAKE_BURNING_TARGET), target: SHAKE_BURNING_TARGET }; + progress.WORKOUT_IRON_BREAKER = { current: Math.min(user.totalSetsCompleted, IRON_BREAKER_SETS_TARGET), target: IRON_BREAKER_SETS_TARGET }; + progress.STREAK_INSUBMERSIBLE = { current: Math.min(user.consecutiveStreakGelSaves, STREAK_GEL_SAVE_TARGET), target: STREAK_GEL_SAVE_TARGET }; + + const lobbyCount = await countLobbyInstigator(user._id); + progress.LOBBY_INSTIGATOR = { current: Math.min(lobbyCount, LOBBY_INSTIGATOR_TARGET), target: LOBBY_INSTIGATOR_TARGET }; + + const pokeCount = await countDistinctShakeTargetsToday(user._id); + progress.SOCIAL_POKE_STRIKER = { current: Math.min(pokeCount, POKE_STRIKER_DISTINCT_TARGET), target: POKE_STRIKER_DISTINCT_TARGET }; + + return progress; +} + +// ───────────────────────────────────────────────────────────────────────────── +// getMyTitles GET /api/profile/titles +// ───────────────────────────────────────────────────────────────────────────── +exports.getMyTitles = async (req, res, next) => { + try { + const user = await User.findById(req.user.id); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + const unlockedSet = new Set(user.unlockedTitles); + const progressById = await computeProgress(user); + + const titles = Object.values(TITLE_CATALOG).map((entry) => ({ + ...entry, + unlocked: unlockedSet.has(entry.id), + progress: progressById[entry.id] || null, + })); + + return res.status(200).json({ + success: true, + titles, + equippedTitle: user.equippedTitle, + }); + } catch (err) { + next(err); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// equipTitle POST /api/profile/equip-title +// ───────────────────────────────────────────────────────────────────────────── +exports.equipTitle = async (req, res, next) => { + try { + const { titleId } = req.body || {}; + + // titleId null/undefined explicite → déséquipe (retour au pseudo seul). + if (titleId === null || titleId === undefined) { + const user = await User.findByIdAndUpdate( + req.user.id, + { $set: { equippedTitle: null } }, + { new: true }, + ).select('equippedTitle'); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + return res.status(200).json({ success: true, equippedTitle: user.equippedTitle }); + } + + if (!TITLE_CATALOG[titleId]) { + return next(createError('Titre inconnu.', 400)); + } + + const user = await User.findById(req.user.id); + if (!user) return next(createError('Utilisateur introuvable.', 404)); + + if (!user.unlockedTitles.includes(titleId)) { + return next(createError("Ce titre n'est pas encore débloqué.", 403)); + } + + user.equippedTitle = titleId; + await user.save(); + + return res.status(200).json({ success: true, equippedTitle: user.equippedTitle }); + } catch (err) { + next(err); + } +}; + +exports.checkAndUnlockTitles = checkAndUnlockTitles; diff --git a/back/controllers/workoutLobby.controller.js b/back/controllers/workoutLobby.controller.js index 215f455..39ec0a3 100644 --- a/back/controllers/workoutLobby.controller.js +++ b/back/controllers/workoutLobby.controller.js @@ -6,6 +6,7 @@ const User = require('../models/User'); const Friendship = require('../models/Friendship'); const { sendPushToUser } = require('../services/push.service'); const { checkAndUnlockAchievements } = require('./reward.controller'); +const { checkAndUnlockTitles } = require('./title.controller'); const { MAX_MEMBERS } = WorkoutLobby; const MEMBER_PUBLIC_FIELDS = 'pseudo level rank equippedFrame'; @@ -357,8 +358,9 @@ exports.finishLobby = async (req, res, next) => { await lobby.save(); let newlyUnlockedByUser = {}; + let newlyUnlockedTitlesByUser = {}; if (allFinished) { - // Best-effort : les trophées ne doivent jamais faire échouer la clôture. + // Best-effort : les trophées/titres ne doivent jamais faire échouer la clôture. try { // Incrémente le compteur AVANT de vérifier les trophées gradués // (MULTI_SESSIONS_5 / MULTI_SESSIONS_30), sinon le seuil serait @@ -372,8 +374,13 @@ exports.finishLobby = async (req, res, next) => { lobby.members.map((m) => checkAndUnlockAchievements(m.user.toString())), ); lobby.members.forEach((m, i) => { newlyUnlockedByUser[m.user.toString()] = results[i]; }); + + const titleResults = await Promise.all( + lobby.members.map((m) => checkAndUnlockTitles(m.user.toString())), + ); + lobby.members.forEach((m, i) => { newlyUnlockedTitlesByUser[m.user.toString()] = titleResults[i]; }); } catch (_) { - // ignore — la clôture du lobby ne doit pas dépendre des trophées. + // ignore — la clôture du lobby ne doit pas dépendre des trophées/titres. } } @@ -384,6 +391,7 @@ exports.finishLobby = async (req, res, next) => { lobby: serializeLobby(lobby), completed: allFinished, newlyUnlocked: allFinished ? (newlyUnlockedByUser[myId] ?? []) : [], + newlyUnlockedTitles: allFinished ? (newlyUnlockedTitlesByUser[myId] ?? []) : [], }); } catch (err) { next(err); diff --git a/back/data/titleCatalog.js b/back/data/titleCatalog.js new file mode 100644 index 0000000..32a7715 --- /dev/null +++ b/back/data/titleCatalog.js @@ -0,0 +1,156 @@ +'use strict'; + +// ─── Catalogue des Titres déblocables (Section X) ────────────────────────────── +// Titre = cosmétique textuel affiché sous le pseudo (profil public/privé), +// équipable un par un via POST /api/profile/equip-title. Les CONDITIONS sont +// évaluées côté serveur (voir title.controller.js → checkAndUnlockTitles) — +// ce fichier ne porte que les MÉTADONNÉES d'affichage, à l'image de +// ACHIEVEMENT_CATALOG dans reward.controller.js. +// +// `rarity` pilote la couleur de lueur affichée par TitlePickerScreen côté +// front (mêmes teintes que l'inventaire : commun/rare/épique/légendaire/unique +// — voir Colors.uniqueBlood* pour la rareté "unique"). +// +// `condition` est un texte humain affiché dans l'InfoModal d'un titre encore +// verrouillé (Section X — "quête exacte à accomplir"). + +const TITLE_CATALOG = { + PERFORM_LEVEL_10: { + id: 'PERFORM_LEVEL_10', + label: 'Le Rescapé', + description: 'Atteindre le niveau 10.', + condition: 'Atteins le niveau 10.', + category: 'performance', + rarity: 'common', + }, + PERFORM_LEVEL_50: { + id: 'PERFORM_LEVEL_50', + label: 'Le Titan', + description: 'Atteindre le niveau 50.', + condition: 'Atteins le niveau 50.', + category: 'performance', + rarity: 'legendary', + }, + REFERRAL_EARLY: { + id: 'REFERRAL_EARLY', + label: "L'Ancien", + description: 'Avoir lié son compte via parrainage à son inscription.', + condition: "Crée ton compte via un lien de parrainage (champ optionnel à l'inscription).", + category: 'social', + rarity: 'rare', + }, + PR_HEAVY_100: { + id: 'PR_HEAVY_100', + label: 'Force de la Nature', + description: 'Décrocher un record personnel de plus de 100 kg sur un exercice majeur.', + condition: 'Bats un record personnel à plus de 100 kg sur un exercice majeur (Développé couché, Squat, Soulevé de terre…).', + category: 'performance', + rarity: 'epic', + }, + LOOT_LEGENDARY: { + id: 'LOOT_LEGENDARY', + label: 'Main Dorée', + description: 'Obtenir un objet Légendaire dans un coffre.', + condition: 'Ouvre un coffre et obtiens un objet Légendaire.', + category: 'inventory', + rarity: 'legendary', + }, + LOOT_BLOOD_UNIQUE: { + id: 'LOOT_BLOOD_UNIQUE', + label: 'Élu du Sang', + description: 'Obtenir un item ou cadre Unique Rouge Sang.', + condition: 'Débloque un cosmétique Unique Rouge Sang (cadre, couleur ou thème).', + category: 'inventory', + rarity: 'unique', + }, + INVENTORY_HOARDER: { + id: 'INVENTORY_HOARDER', + label: 'Le Collectionneur', + description: 'Posséder au moins 10 objets en même temps.', + condition: "Accumule au moins 10 objets dans ton inventaire, tous types confondus.", + category: 'inventory', + rarity: 'rare', + }, + MULTI_SESSIONS_30_TITLE: { + id: 'MULTI_SESSIONS_30_TITLE', + label: "Frère d'Armes", + description: 'Avoir validé 30 séances en mode Multi.', + condition: 'Termine 30 séances en Lobby Multi.', + category: 'social', + rarity: 'epic', + }, + SOLO_SHADOW_WORK: { + id: 'SOLO_SHADOW_WORK', + label: 'Loup Solitaire', + description: "S'entraîner seul pendant qu'un de ses groupes est actif.", + condition: "Termine une séance en solo pendant qu'un coéquipier de ton groupe a une séance active en direct.", + category: 'social', + rarity: 'rare', + }, + SHAME_BURNING: { + id: 'SHAME_BURNING', + label: 'Bourreau des Cœurs', + description: 'Avoir utilisé le bouton "Secouer" plus de 15 fois.', + condition: 'Utilise le bouton "Secouer" plus de 15 fois au total.', + category: 'social', + rarity: 'epic', + }, + SHAME_REPENTANCE: { + id: 'SHAME_REPENTANCE', + label: 'Le Repentir', + description: "Sortir du Hall of Shame en validant sa séance moins de 2h après y avoir été affiché.", + condition: 'Valide ta séance dans les 2 heures suivant ton apparition dans le Hall of Shame.', + category: 'social', + rarity: 'rare', + }, + WORKOUT_IRON_BREAKER: { + id: 'WORKOUT_IRON_BREAKER', + label: 'Le Briseur de Fonte', + description: 'Valider 100 séries au total.', + condition: 'Valide 100 séries au total, toutes séances confondues.', + category: 'performance', + rarity: 'epic', + }, + WORKOUT_NIGHT_OWL: { + id: 'WORKOUT_NIGHT_OWL', + label: 'L\'Ombre de la Salle', + description: 'Lancer une séance entre minuit et 5h du matin.', + condition: 'Lance une séance entre minuit et 5h du matin.', + category: 'performance', + rarity: 'rare', + }, + STREAK_INSUBMERSIBLE: { + id: 'STREAK_INSUBMERSIBLE', + label: "L'Insubmersible", + description: "Sauver sa streak 3 fois de suite avec un Gel de Streak.", + condition: "Sauve ta streak personnelle 3 fois d'affilée grâce à un Gel de Streak, sans rupture non couverte entre les deux.", + category: 'streak', + rarity: 'epic', + }, + GROUP_GUILD_MASTER: { + id: 'GROUP_GUILD_MASTER', + label: 'Maître de Guilde', + description: 'Créer un groupe de 5 et le maintenir 7 jours.', + condition: 'En tant que créateur, maintiens un groupe à 5 membres pendant 7 jours.', + category: 'social', + rarity: 'legendary', + }, + SOCIAL_POKE_STRIKER: { + id: 'SOCIAL_POKE_STRIKER', + label: 'Fléau de la Paresse', + description: 'Secouer 3 membres différents le même jour.', + condition: 'Secoue 3 coéquipiers différents dans la même journée.', + category: 'social', + rarity: 'rare', + }, + LOBBY_INSTIGATOR: { + id: 'LOBBY_INSTIGATOR', + label: "L'Instigateur", + description: 'Être le créateur de 20 Lobbies Multi validés.', + condition: 'Crée et termine 20 Lobbies Multi en tant que créateur.', + category: 'social', + rarity: 'legendary', + }, +}; + +module.exports = { TITLE_CATALOG }; diff --git a/back/models/StreakGroup.js b/back/models/StreakGroup.js index f57539e..ce33858 100644 --- a/back/models/StreakGroup.js +++ b/back/models/StreakGroup.js @@ -44,6 +44,13 @@ const StreakGroupSchema = new mongoose.Schema( // validée avec succès — reste affiché jusque-là. shameBreakers: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], + // Horodatage du dernier envoi au Hall of Shame — condition du titre + // SHAME_REPENTANCE ("sortir du Hall of Shame en validant sa séance moins + // de 2h après y avoir été affiché", voir title.controller.js). Un seul + // timestamp pour tout le batch de breakers : ils sont détectés au même + // instant par detectAndApplyStreakBreak. + shameBreakersShamedAt: { type: Date, default: null }, + // Historique des secousses (bouton "Secouer") — limite chaque paire // (from, to) à 1 secousse par jour civil, et permet au front de griser // le bouton pour les membres déjà secoués aujourd'hui (voir shakeMember diff --git a/back/models/User.js b/back/models/User.js index 33d5f17..2197eb8 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -154,6 +154,27 @@ const UserSchema = new mongoose.Schema( // gradués MULTI_SESSIONS_5 / MULTI_SESSIONS_30 (reward.controller.js). totalMultiSessions: { type: Number, default: 0, min: 0 }, + // ── Titres déblocables (Section X) ──────────────────────────────────────── + // IDs débloqués (voir data/titleCatalog.js pour le catalogue complet) et + // titre actuellement affiché sous le pseudo — équipable uniquement parmi + // les IDs présents dans unlockedTitles (voir title.controller.js). + unlockedTitles: { type: [String], default: [] }, + equippedTitle: { type: String, default: null }, + + // Compteurs dédiés aux conditions de titres qui ne se déduisent pas d'une + // requête ponctuelle (voir checkAndUnlockTitles dans title.controller.js) : + // - totalShakesSent : nombre total de fois où CET utilisateur a + // secoué quelqu'un (bouton "Secouer", tous groupes confondus). + // - totalSetsCompleted : cumul des séries validées, toutes séances + // confondues (incrémenté à la clôture backend d'une séance). + // - consecutiveStreakGelSaves : nombre de fois D'AFFILÉE où un Gel de + // Streak a sauvé sa streak personnelle sans rupture intercalée (remis à + // 0 dès qu'une rupture NON couverte par un gel survient — voir + // detectAndApplyStreakBreak dans groupStreak.controller.js). + totalShakesSent: { type: Number, default: 0, min: 0 }, + totalSetsCompleted: { type: Number, default: 0, min: 0 }, + consecutiveStreakGelSaves: { type: Number, default: 0, min: 0 }, + // Cosmétiques Uniques définitivement débloqués (réclamés depuis // l'inventaire — voir inventory.controller.js → claimUniqueItem). // Clés libres du catalogue front (BorderPicker.js / profileThemes.js) : diff --git a/back/routes/debug.routes.js b/back/routes/debug.routes.js index 9ee6702..1cdc69b 100644 --- a/back/routes/debug.routes.js +++ b/back/routes/debug.routes.js @@ -32,4 +32,7 @@ router.post('/godmode/simulate-searchable-friend', debug.simulateSearchableFrien // ── God Mode : Vague 3 (Lobby Multi) ───────────────────────────────────────── router.post('/godmode/simulate-lobby-invite', debug.simulateLobbyInvite); +// ── God Mode : Titres (Section X) ──────────────────────────────────────────── +router.post('/godmode/give-all-titles', debug.giveAllTitles); + module.exports = router; diff --git a/back/routes/profile.routes.js b/back/routes/profile.routes.js new file mode 100644 index 0000000..2bb72a5 --- /dev/null +++ b/back/routes/profile.routes.js @@ -0,0 +1,14 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth.middleware'); +const title = require('../controllers/title.controller'); + +router.use(auth); + +// ── Titres déblocables (Section X) ──────────────────────────────────────────── +router.get('/titles', title.getMyTitles); +router.post('/equip-title', title.equipTitle); + +module.exports = router; diff --git a/back/services/auth.service.js b/back/services/auth.service.js index f07d72c..ac9a963 100644 --- a/back/services/auth.service.js +++ b/back/services/auth.service.js @@ -144,6 +144,10 @@ class AuthService { // auth.service → reward.controller → … au chargement des modules) const { checkAndUnlockAchievements } = require("../controllers/reward.controller"); await checkAndUnlockAchievements(referrer._id.toString()); + + // Titre REFERRAL_EARLY ("L'Ancien") du FILLEUL — lié dès l'inscription. + const { checkAndUnlockTitles } = require("../controllers/title.controller"); + await checkAndUnlockTitles(newUser._id.toString()); } catch (err) { console.error("⚠️ [register] Récompenses de parrainage partielles :", err.message); } diff --git a/back/services/workout.service.js b/back/services/workout.service.js index 63e5990..95eff41 100644 --- a/back/services/workout.service.js +++ b/back/services/workout.service.js @@ -2,6 +2,7 @@ const Workout = require("../models/Workout"); const User = require("../models/User"); const { levelFromXP } = require("../utils/levelHelpers"); const { addItemAtomic } = require("./inventory.service"); +const { checkAndUnlockTitles } = require("../controllers/title.controller"); // ── Coffres à l'effort (Brique II) ─────────────────────────────────────────── // 1 coffre (CHEST_KEY) tous les CHEST_MINUTES_THRESHOLD minutes de séance @@ -145,6 +146,19 @@ class WorkoutService { await user.save(); } + // Titres (Section X) : cumul de séries + conditions événementielles + // (night owl, loup solitaire...). Best-effort, ne bloque jamais la + // clôture déjà actée ci-dessus. + let newlyUnlockedTitles = []; + if (user && workout.setsCompleted > 0) { + try { + await User.updateOne({ _id: userId }, { $inc: { totalSetsCompleted: workout.setsCompleted } }); + newlyUnlockedTitles = await checkAndUnlockTitles(userId, { finishedWorkout: workout }); + } catch (_) { + // ignore + } + } + return { workout, stats: { @@ -155,6 +169,7 @@ class WorkoutService { durationSeconds: workout.durationSeconds, userXP: user ? user.xp : null, userLevel: user ? user.level : null, + newlyUnlockedTitles, }, }; } @@ -203,6 +218,17 @@ class WorkoutService { chestInfo = await accrueMinutesAndAwardChests(userId, Math.floor(duration / 60)); } + // Titres (Section X) — mêmes conditions que completeWorkout ci-dessus. + let newlyUnlockedTitles = []; + if (user && workout.setsCompleted > 0) { + try { + await User.updateOne({ _id: userId }, { $inc: { totalSetsCompleted: workout.setsCompleted } }); + newlyUnlockedTitles = await checkAndUnlockTitles(userId, { finishedWorkout: workout }); + } catch (_) { + // ignore + } + } + return { workout, stats: { @@ -212,6 +238,7 @@ class WorkoutService { userLevel: user ? user.level : null, chestsAwarded: chestInfo.chestsAwarded, totalWorkoutMinutes: chestInfo.totalWorkoutMinutes, + newlyUnlockedTitles, }, }; } diff --git a/back/tests/debug.test.js b/back/tests/debug.test.js index 80a5d3d..a05afff 100644 --- a/back/tests/debug.test.js +++ b/back/tests/debug.test.js @@ -529,4 +529,43 @@ describe('God Mode — outils de test Vague 1', () => { expect(res.statusCode).toBe(401); }); }); + + describe('POST /api/debug/godmode/give-all-titles', () => { + it('✅ Débloque tous les titres du catalogue', async () => { + const { TITLE_CATALOG } = require('../data/titleCatalog'); + const res = await request(app) + .post('/api/debug/godmode/give-all-titles') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.unlockedTitles.length).toBe(Object.keys(TITLE_CATALOG).length); + + const user = await User.findById(alice.userId); + expect(user.unlockedTitles.length).toBe(Object.keys(TITLE_CATALOG).length); + }); + + it('✅ Débloque au passage les trophées TITLE_FIRST et TITLE_COLLECTOR_5', async () => { + const res = await request(app) + .post('/api/debug/godmode/give-all-titles') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.body.newlyUnlocked).toContain('TITLE_FIRST'); + expect(res.body.newlyUnlocked).toContain('TITLE_COLLECTOR_5'); + }); + + it('🔁 Idempotent : rejouer ne duplique pas les titres', async () => { + await request(app).post('/api/debug/godmode/give-all-titles').set('Authorization', `Bearer ${alice.token}`); + const second = await request(app) + .post('/api/debug/godmode/give-all-titles') + .set('Authorization', `Bearer ${alice.token}`); + + const { TITLE_CATALOG } = require('../data/titleCatalog'); + expect(second.body.unlockedTitles.length).toBe(Object.keys(TITLE_CATALOG).length); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/debug/godmode/give-all-titles'); + expect(res.statusCode).toBe(401); + }); + }); }); diff --git a/back/tests/titles.test.js b/back/tests/titles.test.js new file mode 100644 index 0000000..68481ff --- /dev/null +++ b/back/tests/titles.test.js @@ -0,0 +1,398 @@ +'use strict'; + +const request = require('supertest'); +const mongoose = require('mongoose'); +const app = require('../app'); +const User = require('../models/User'); +const StreakGroup = require('../models/StreakGroup'); +const Workout = require('../models/Workout'); +const WorkoutLobby = require('../models/WorkoutLobby'); +const ExerciseRecord = require('../models/ExerciseRecord'); +const { checkAndUnlockTitles } = require('../controllers/title.controller'); + +async function createAndLoginUser(pseudo, email) { + await request(app).post('/api/auth/register').send({ pseudo, email, password: 'Password123!' }); + await User.updateOne({ email }, { isVerified: true }); + const loginRes = await request(app).post('/api/auth/login').send({ email, password: 'Password123!' }); + const user = await User.findOne({ email }).select('_id'); + return { token: loginRes.body.token, userId: user._id.toString() }; +} + +describe('Titres déblocables — Section X', () => { + let alice, bob; + + beforeAll(async () => { + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGO_URI); + } + }); + + afterAll(async () => { + await User.deleteMany({}); + await StreakGroup.deleteMany({}); + await Workout.deleteMany({}); + await WorkoutLobby.deleteMany({}); + await ExerciseRecord.deleteMany({}); + await mongoose.connection.close(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await StreakGroup.deleteMany({}); + await Workout.deleteMany({}); + await WorkoutLobby.deleteMany({}); + await ExerciseRecord.deleteMany({}); + alice = await createAndLoginUser('AliceTitle', 'alice.title@athly.fr'); + bob = await createAndLoginUser('BobTitle', 'bob.title@athly.fr'); + }); + + describe('GET /api/profile/titles', () => { + it('✅ Renvoie les 17 titres du catalogue, tous verrouillés par défaut', async () => { + const res = await request(app) + .get('/api/profile/titles') + .set('Authorization', `Bearer ${alice.token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.titles.length).toBe(17); + expect(res.body.titles.every((t) => t.unlocked === false)).toBe(true); + expect(res.body.equippedTitle).toBeNull(); + }); + + it('✅ Reflète unlockedTitles et fournit une progression pour les titres à seuil', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { unlockedTitles: ['PERFORM_LEVEL_10'], level: 12 } }); + + const res = await request(app) + .get('/api/profile/titles') + .set('Authorization', `Bearer ${alice.token}`); + + const lvl10 = res.body.titles.find((t) => t.id === 'PERFORM_LEVEL_10'); + expect(lvl10.unlocked).toBe(true); + expect(lvl10.progress).toEqual({ current: 10, target: 10 }); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).get('/api/profile/titles'); + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/profile/equip-title', () => { + it('✅ Équipe un titre débloqué', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { unlockedTitles: ['PERFORM_LEVEL_10'] } }); + + const res = await request(app) + .post('/api/profile/equip-title') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titleId: 'PERFORM_LEVEL_10' }); + + expect(res.statusCode).toBe(200); + expect(res.body.equippedTitle).toBe('PERFORM_LEVEL_10'); + + const user = await User.findById(alice.userId); + expect(user.equippedTitle).toBe('PERFORM_LEVEL_10'); + }); + + it("❌ 403 si le titre n'est pas débloqué", async () => { + const res = await request(app) + .post('/api/profile/equip-title') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titleId: 'PERFORM_LEVEL_50' }); + + expect(res.statusCode).toBe(403); + }); + + it('❌ 400 si titleId inconnu', async () => { + const res = await request(app) + .post('/api/profile/equip-title') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titleId: 'NOT_A_REAL_TITLE' }); + + expect(res.statusCode).toBe(400); + }); + + it('✅ Déséquipe si titleId est null', async () => { + await User.updateOne( + { _id: alice.userId }, + { $set: { unlockedTitles: ['PERFORM_LEVEL_10'], equippedTitle: 'PERFORM_LEVEL_10' } }, + ); + + const res = await request(app) + .post('/api/profile/equip-title') + .set('Authorization', `Bearer ${alice.token}`) + .send({ titleId: null }); + + expect(res.statusCode).toBe(200); + expect(res.body.equippedTitle).toBeNull(); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/profile/equip-title').send({ titleId: 'PERFORM_LEVEL_10' }); + expect(res.statusCode).toBe(401); + }); + }); + + describe('checkAndUnlockTitles — conditions', () => { + it('✅ PERFORM_LEVEL_10 / PERFORM_LEVEL_50 selon le niveau', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { level: 10 } }); + let unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('PERFORM_LEVEL_10'); + expect(unlocked).not.toContain('PERFORM_LEVEL_50'); + + await User.updateOne({ _id: alice.userId }, { $set: { level: 50 } }); + unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('PERFORM_LEVEL_50'); + }); + + it('✅ REFERRAL_EARLY si referredBy est renseigné', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { referredBy: bob.userId } }); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('REFERRAL_EARLY'); + }); + + it('✅ LOOT_LEGENDARY si un item Légendaire est en inventaire', async () => { + await User.updateOne( + { _id: alice.userId }, + { $set: { inventory: [{ itemType: 'LEVEL_COUPON', rarity: 'legendary', quantity: 1 }] } }, + ); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('LOOT_LEGENDARY'); + }); + + it('✅ LOOT_BLOOD_UNIQUE via un item Unique en inventaire OU un cosmétique déjà réclamé', async () => { + await User.updateOne( + { _id: alice.userId }, + { $set: { inventory: [{ itemType: 'FRAME_COLOR_BLOOD_SANG', rarity: 'unique', quantity: 1 }] } }, + ); + let unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('LOOT_BLOOD_UNIQUE'); + + // Bob : item Unique déjà consommé (claimUniqueItem) — plus en inventaire, + // mais présent dans unlockedCosmetics. + await User.updateOne({ _id: bob.userId }, { $set: { unlockedCosmetics: ['FRAME_COLOR_BLOODSANG'] } }); + unlocked = await checkAndUnlockTitles(bob.userId); + expect(unlocked).toContain('LOOT_BLOOD_UNIQUE'); + }); + + it('✅ INVENTORY_HOARDER dès 10 objets cumulés', async () => { + await User.updateOne( + { _id: alice.userId }, + { $set: { inventory: [{ itemType: 'ENERGY_DRINK', rarity: 'common', quantity: 10 }] } }, + ); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('INVENTORY_HOARDER'); + }); + + it('✅ MULTI_SESSIONS_30_TITLE à 30 séances Multi cumulées', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { totalMultiSessions: 30 } }); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('MULTI_SESSIONS_30_TITLE'); + }); + + it('✅ SHAME_BURNING dès 15 secousses envoyées', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { totalShakesSent: 15 } }); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('SHAME_BURNING'); + }); + + it('✅ WORKOUT_IRON_BREAKER à 100 séries cumulées', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { totalSetsCompleted: 100 } }); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('WORKOUT_IRON_BREAKER'); + }); + + it('✅ STREAK_INSUBMERSIBLE à 3 sauvetages de streak consécutifs', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { consecutiveStreakGelSaves: 3 } }); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('STREAK_INSUBMERSIBLE'); + }); + + it('✅ PR_HEAVY_100 sur un record > 100kg pour un exercice majeur', async () => { + const workout = await Workout.create({ user: alice.userId, status: 'completed' }); + await ExerciseRecord.create({ + user: alice.userId, + workout: workout._id, + exerciceNom: 'Squat', + series: [{ poids: 120, repetitions: 3 }], + }); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('PR_HEAVY_100'); + }); + + it("❌ PR_HEAVY_100 non débloqué si l'exercice n'est pas majeur", async () => { + const workout = await Workout.create({ user: alice.userId, status: 'completed' }); + await ExerciseRecord.create({ + user: alice.userId, + workout: workout._id, + exerciceNom: 'Extension mollets debout', + series: [{ poids: 150, repetitions: 3 }], + }); + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).not.toContain('PR_HEAVY_100'); + }); + + it('✅ WORKOUT_NIGHT_OWL pour une séance lancée entre minuit et 5h', async () => { + // mongoose (timestamps: true) ignore tout `createdAt` fourni via + // updateOne — il doit être fourni dès la création du document. + const nightDate = new Date(); + nightDate.setUTCHours(2, 0, 0, 0); + await Workout.create({ user: alice.userId, status: 'in_progress', createdAt: nightDate }); + + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('WORKOUT_NIGHT_OWL'); + }); + + it('✅ LOBBY_INSTIGATOR à 20 lobbies créés et terminés', async () => { + const lobbies = Array.from({ length: 20 }, () => ({ + creatorId: alice.userId, + members: [{ user: alice.userId, status: 'finished' }], + memberCount: 1, + status: 'completed', + })); + await WorkoutLobby.insertMany(lobbies); + + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('LOBBY_INSTIGATOR'); + }); + + it("❌ LOBBY_INSTIGATOR non débloqué si l'utilisateur n'est pas créateur", async () => { + const lobbies = Array.from({ length: 20 }, () => ({ + creatorId: bob.userId, + members: [{ user: bob.userId, status: 'finished' }, { user: alice.userId, status: 'finished' }], + memberCount: 2, + status: 'completed', + })); + await WorkoutLobby.insertMany(lobbies); + + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).not.toContain('LOBBY_INSTIGATOR'); + }); + + it('✅ GROUP_GUILD_MASTER : créateur, groupe à 5 membres depuis plus de 7 jours', async () => { + const others = []; + for (let i = 0; i < 4; i++) { + others.push(await createAndLoginUser(`Guild${i}`, `guild${i}@athly.fr`)); + } + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + await StreakGroup.create({ + members: [alice.userId, ...others.map((o) => o.userId)], + createdAt: eightDaysAgo, + }); + + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('GROUP_GUILD_MASTER'); + }); + + it("❌ GROUP_GUILD_MASTER non débloqué si l'utilisateur n'est pas le créateur (members[0])", async () => { + const others = []; + for (let i = 0; i < 4; i++) { + others.push(await createAndLoginUser(`Guild2_${i}`, `guild2_${i}@athly.fr`)); + } + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + await StreakGroup.create({ + members: [others[0].userId, alice.userId, ...others.slice(1).map((o) => o.userId)], + createdAt: eightDaysAgo, + }); + + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).not.toContain('GROUP_GUILD_MASTER'); + }); + + it('✅ SOCIAL_POKE_STRIKER : 3 cibles différentes secouées le même jour', async () => { + const others = []; + for (let i = 0; i < 3; i++) { + others.push(await createAndLoginUser(`Poke${i}`, `poke${i}@athly.fr`)); + } + await StreakGroup.create({ + members: [alice.userId, ...others.map((o) => o.userId)], + shakes: others.map((o) => ({ from: alice.userId, to: o.userId, date: new Date() })), + }); + + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('SOCIAL_POKE_STRIKER'); + }); + + it('✅ SHAME_REPENTANCE : séance validée dans les 2h suivant le Hall of Shame', async () => { + const shamedAt = new Date(Date.now() - 60 * 60 * 1000); // il y a 1h + await StreakGroup.create({ + members: [alice.userId, bob.userId], + shameBreakers: [alice.userId], + shameBreakersShamedAt: shamedAt, + }); + await Workout.create({ + user: alice.userId, + status: 'finished', + completedAt: new Date(shamedAt.getTime() + 30 * 60 * 1000), // 30 min après + }); + + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).toContain('SHAME_REPENTANCE'); + }); + + it('❌ SHAME_REPENTANCE non débloqué si la séance arrive après la fenêtre de 2h', async () => { + const shamedAt = new Date(Date.now() - 5 * 60 * 60 * 1000); // il y a 5h + await StreakGroup.create({ + members: [alice.userId, bob.userId], + shameBreakers: [alice.userId], + shameBreakersShamedAt: shamedAt, + }); + await Workout.create({ + user: alice.userId, + status: 'finished', + completedAt: new Date(), // maintenant, largement après les 2h + }); + + const unlocked = await checkAndUnlockTitles(alice.userId); + expect(unlocked).not.toContain('SHAME_REPENTANCE'); + }); + + it("✅ SOLO_SHADOW_WORK : séance solo terminée pendant qu'un coéquipier a une séance active", async () => { + await StreakGroup.create({ members: [alice.userId, bob.userId] }); + + await Workout.create({ user: bob.userId, status: 'in_progress' }); + + const aliceWorkout = await Workout.create({ + user: alice.userId, + status: 'finished', + completedAt: new Date(), + }); + + const unlocked = await checkAndUnlockTitles(alice.userId, { finishedWorkout: aliceWorkout }); + expect(unlocked).toContain('SOLO_SHADOW_WORK'); + }); + + it('✅ Idempotent : un titre déjà débloqué ne redescend jamais et ne se re-notifie pas', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { level: 10 } }); + const first = await checkAndUnlockTitles(alice.userId); + expect(first).toContain('PERFORM_LEVEL_10'); + + const second = await checkAndUnlockTitles(alice.userId); + expect(second).not.toContain('PERFORM_LEVEL_10'); + + const user = await User.findById(alice.userId); + expect(user.unlockedTitles.filter((t) => t === 'PERFORM_LEVEL_10').length).toBe(1); + }); + + it('✅ Débloque le trophée TITLE_FIRST au tout premier titre obtenu', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { level: 10 } }); + await checkAndUnlockTitles(alice.userId); + + const user = await User.findById(alice.userId); + expect(user.achievements.some((a) => a.achievementId === 'TITLE_FIRST')).toBe(true); + expect(user.achievements.some((a) => a.achievementId === 'TITLE_COLLECTOR_5')).toBe(false); + }); + + it('✅ Débloque le trophée TITLE_COLLECTOR_5 au 5e titre obtenu', async () => { + await User.updateOne({ _id: alice.userId }, { + $set: { + unlockedTitles: ['REFERRAL_EARLY', 'LOOT_LEGENDARY', 'LOOT_BLOOD_UNIQUE', 'INVENTORY_HOARDER'], + level: 10, + }, + }); + await checkAndUnlockTitles(alice.userId); + + const user = await User.findById(alice.userId); + expect(user.unlockedTitles.length).toBe(5); + expect(user.achievements.some((a) => a.achievementId === 'TITLE_COLLECTOR_5')).toBe(true); + }); + }); +}); diff --git a/front/src/components/cards/SetRow.js b/front/src/components/cards/SetRow.js index aebe6df..81f8cb1 100644 --- a/front/src/components/cards/SetRow.js +++ b/front/src/components/cards/SetRow.js @@ -7,8 +7,8 @@ import { StyleSheet, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import * as Haptics from 'expo-haptics'; import { Colors } from '../../constants/theme'; +import { haptics } from '../../services/haptics.service'; // Ligne de série : [-] SET | POIDS (KG) | REPS | VALIDER // @@ -35,12 +35,13 @@ function SetRow({ index, setData = {}, onChange, onToggle, onRemove }) { const reps = setData.reps ? String(setData.reps) : ''; const handleToggle = useCallback(() => { - try { Haptics.selectionAsync(); } catch (e) {} + // Validation d'une série : vibration légère et rapide. + if (!completed) haptics.success(); else haptics.selection(); if (onToggle) onToggle(); - }, [onToggle]); + }, [onToggle, completed]); const handleRemove = useCallback(() => { - try { Haptics.selectionAsync(); } catch (e) {} + haptics.selection(); if (onRemove) onRemove(); }, [onRemove]); diff --git a/front/src/components/profile/HeroLevelCard.js b/front/src/components/profile/HeroLevelCard.js index f8e88de..afa646d 100644 --- a/front/src/components/profile/HeroLevelCard.js +++ b/front/src/components/profile/HeroLevelCard.js @@ -25,6 +25,8 @@ export default function HeroLevelCard({ shapeId = 'circle', colorId = 'none', profileTheme = null, // PROFILE_THEMES entry — overrides visual rank when set + titleLabel = null, // Titre équipé (Section X) — libellé résolu côté appelant + titleColor = Colors.textMuted, // Couleur de rareté du titre équipé }) { const { level, currentInLevel, neededForNext, progress } = useMemo( () => xpToLevel(totalXP), @@ -134,6 +136,17 @@ export default function HeroLevelCard({ )} + {/* ── Titre équipé (Section X) ── */} + {titleLabel && ( + + + {titleLabel} + + )} + {/* ── Rang ── */} {rank.name} @@ -271,6 +284,26 @@ const styles = StyleSheet.create({ fontSize: 20, fontWeight: '900', }, + titleBadge: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'center', + marginTop: 5, + paddingVertical: 4, + paddingHorizontal: 10, + borderRadius: 20, + borderWidth: 1, + maxWidth: '90%', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.7, + shadowRadius: 8, + elevation: 5, + }, + titleBadgeText: { + fontSize: 11.5, + fontWeight: '800', + letterSpacing: 0.2, + }, // ── Rang ────────────────────────────────────────────────────────────────────── rankName: { diff --git a/front/src/components/profile/TitlePickerModal.js b/front/src/components/profile/TitlePickerModal.js new file mode 100644 index 0000000..0285a31 --- /dev/null +++ b/front/src/components/profile/TitlePickerModal.js @@ -0,0 +1,249 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Modal } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +import { Colors } from '../../constants/theme'; +import { getTitles, equipTitle } from '../../services/title.service'; +import { RARITY_META } from '../../services/inventory.service'; +import { haptics } from '../../services/haptics.service'; + +// ─── TitleInfoModal ─────────────────────────────────────────────────────────── +// Affiche la quête exacte pour un titre encore verrouillé, avec une barre de +// progression quand le titre a un seuil numérique (voir progress côté backend). +function TitleInfoModal({ title, onClose }) { + const visible = Boolean(title); + const meta = title ? (RARITY_META[title.rarity] || RARITY_META.common) : RARITY_META.common; + const progress = title?.progress; + const pct = progress ? Math.max(0, Math.min(1, progress.current / progress.target)) : 0; + + return ( + + + + + + + {title?.label} + {title?.condition} + + {progress && ( + + + + + {progress.current} / {progress.target} + + )} + + + Compris + + + + + ); +} + +// ─── TitlePickerModal ───────────────────────────────────────────────────────── +// Sélecteur de titres (Section X) : liste directe, tap sur un titre débloqué +// = équipé immédiatement (pas de preview ni d'étape de confirmation +// intermédiaire — juste choisir parmi ce qu'on a et ce qu'on n'a pas). +// +// Props : visible bool, onClose () => void + +export default function TitlePickerModal({ visible, onClose }) { + const [titles, setTitles] = useState([]); + const [equippedTitleId, setEquippedTitleId] = useState(null); + const [loading, setLoading] = useState(true); + const [equippingId, setEquippingId] = useState(undefined); // id en cours d'équipement, ou undefined + const [infoTitle, setInfoTitle] = useState(null); + + const load = useCallback(async () => { + try { + setLoading(true); + const res = await getTitles(); + setTitles(res.titles); + setEquippedTitleId(res.equippedTitle); + } catch (_) { + // best-effort + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (visible) load(); + }, [visible, load]); + + const handleSelect = useCallback(async (titleId) => { + if (titleId === equippedTitleId) return; + try { + setEquippingId(titleId); + await equipTitle(titleId); + haptics.success(); + setEquippedTitleId(titleId); + } catch (_) { + // best-effort + } finally { + setEquippingId(undefined); + } + }, [equippedTitleId]); + + return ( + + + + + + + + Titres + Choisis le titre affiché sous ton pseudo. + + {loading ? ( + + ) : ( + + + {/* ── Tuile "Aucun titre" ── */} + handleSelect(null)} + activeOpacity={0.85} + > + {equippedTitleId === null && ( + + + + )} + {equippingId === null + ? + : } + Aucun titre + + + {titles.map((title) => { + const meta = RARITY_META[title.rarity] || RARITY_META.common; + const isEquipped = title.id === equippedTitleId; + + if (!title.unlocked) { + return ( + setInfoTitle(title)} + activeOpacity={0.8} + > + + {title.label} + {title.progress && ( + + {title.progress.current}/{title.progress.target} + + )} + + ); + } + + return ( + handleSelect(title.id)} + activeOpacity={0.85} + > + {isEquipped && ( + + + + )} + {equippingId === title.id + ? + : } + {title.label} + + ); + })} + + + )} + + + + setInfoTitle(null)} /> + + ); +} + +const s = StyleSheet.create({ + backdrop: { + flex: 1, backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, + }, + sheet: { + width: '100%', maxHeight: '85%', backgroundColor: '#13131C', + borderRadius: 24, borderWidth: 1, borderColor: `${Colors.primary}38`, + padding: 22, paddingTop: 40, + }, + closeIcon: { position: 'absolute', top: 14, right: 14, zIndex: 1 }, + sheetTitle: { color: Colors.textPrimary, fontSize: 18, fontWeight: '800', textAlign: 'center' }, + sheetSub: { color: Colors.textMuted, fontSize: 12.5, textAlign: 'center', marginTop: 4, marginBottom: 18 }, + + scroll: { maxHeight: 420 }, + grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, paddingBottom: 4 }, + + tile: { + width: '47%', minHeight: 82, borderRadius: 14, borderWidth: 1, + backgroundColor: '#1A1A24', padding: 10, alignItems: 'center', justifyContent: 'center', + }, + tileEquippedNeutral: { + borderColor: Colors.textMuted, backgroundColor: 'rgba(255,255,255,0.06)', + }, + tileCheck: { + position: 'absolute', top: 8, right: 8, width: 17, height: 17, borderRadius: 9, + justifyContent: 'center', alignItems: 'center', + }, + tileLabel: { fontSize: 12, fontWeight: '800', textAlign: 'center', marginTop: 6 }, + tileNoneLabel: { color: Colors.textMuted, fontSize: 12, fontWeight: '700', textAlign: 'center', marginTop: 6 }, + + tileLocked: { + width: '47%', minHeight: 82, borderRadius: 14, borderWidth: 1, borderColor: Colors.borderSubtle, + backgroundColor: 'rgba(255,255,255,0.02)', padding: 10, alignItems: 'center', justifyContent: 'center', + opacity: 0.55, + }, + tileLockedLabel: { color: Colors.textMuted, fontSize: 10.5, fontWeight: '700', textAlign: 'center', marginTop: 6 }, + tileLockedProgress: { color: Colors.textMuted, fontSize: 9.5, marginTop: 4 }, + + // ── TitleInfoModal ───────────────────────────────────────────────────────── + infoBackdrop: { + flex: 1, backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24, + }, + infoCard: { + width: '100%', backgroundColor: '#13131C', borderRadius: 22, borderWidth: 1, + padding: 28, alignItems: 'center', + shadowColor: '#000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.65, shadowRadius: 32, elevation: 20, + }, + infoIconWrap: { + width: 60, height: 60, borderRadius: 18, borderWidth: 1, + justifyContent: 'center', alignItems: 'center', marginBottom: 18, + }, + infoTitle: { color: Colors.textPrimary, fontSize: 18, fontWeight: '800', marginBottom: 10, textAlign: 'center' }, + infoBody: { color: Colors.textSecondary, fontSize: 14, lineHeight: 21, textAlign: 'center', marginBottom: 18 }, + progressWrap: { width: '100%', marginBottom: 22 }, + progressTrack: { height: 8, borderRadius: 4, backgroundColor: '#0A0A0A', overflow: 'hidden', marginBottom: 6 }, + progressFill: { height: '100%', borderRadius: 4 }, + progressLabel: { color: Colors.textMuted, fontSize: 11.5, fontWeight: '700', textAlign: 'center' }, + infoCloseBtn: { + width: '100%', height: 50, borderRadius: 13, justifyContent: 'center', alignItems: 'center', + shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.40, shadowRadius: 12, elevation: 6, + }, + infoCloseTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/screens/Auth/RegisterScreen.js b/front/src/screens/Auth/RegisterScreen.js index b5a7b5a..04aed33 100644 --- a/front/src/screens/Auth/RegisterScreen.js +++ b/front/src/screens/Auth/RegisterScreen.js @@ -11,6 +11,7 @@ import { Colors } from '../../constants/theme'; import AuthInput from '../../components/inputs/AuthInput'; import NotificationBanner from '../../components/common/NotificationBanner'; import { register } from '../../services/auth.service'; +import { haptics } from '../../services/haptics.service'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const HAS_UPPER = /[A-Z]/; @@ -358,6 +359,7 @@ export default function RegisterScreen({ navigation }) { setErrType('error'); setGlobalErr('Cet email est déjà utilisé.'); } else if (msg.toLowerCase().includes('pseudo')) { + haptics.error(); setPseudoErr('Pseudo non autorisé'); setPseudoRejectedVisible(true); } else { diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index 2cf1842..6001f53 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -14,6 +14,7 @@ import { xpForLevel, xpToLevel } from '../../services/stats.service'; import { useAvatarFrame } from '../../hooks/useAvatarFrame'; import { useDevSettings } from '../../hooks/useDevSettings'; import ChestOpeningModal from '../../components/inventory/ChestOpeningModal'; +import { haptics } from '../../services/haptics.service'; const MIN_LEVEL_FOR_CHEST = 11; const RARITY_ORDER = ['unique', 'legendary', 'epic', 'rare', 'common']; @@ -60,6 +61,8 @@ export default function InventoryScreen({ navigation }) { try { const res = await openChest(); if (res.success) { + // Ouverture de coffre : moment marquant → vibration lourde. + haptics.heavy(); // La modale joue l'animation (shake → burst → reveal) setChestModal({ visible: true, drawnItem: res.drawnItem }); } diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js index 454f203..337a4b8 100644 --- a/front/src/screens/Profile/ProfileScreen.js +++ b/front/src/screens/Profile/ProfileScreen.js @@ -26,6 +26,9 @@ import { } from '../../services/stats.service'; import { resolveExerciseMeta } from '../../data/majorExercises'; import { getMyRecords } from '../../services/social.service'; +import { getTitles } from '../../services/title.service'; +import { haptics } from '../../services/haptics.service'; +import { RARITY_META } from '../../services/inventory.service'; import { syncLocalAchievements } from '../../services/reward.service'; import { useAvatarFrame } from '../../hooks/useAvatarFrame'; import { useDevSettings } from '../../hooks/useDevSettings'; @@ -43,6 +46,7 @@ import ActivityHeatmap from '../../components/profile/ActivityHeatmap'; import EmberParticles from '../../components/profile/EmberParticles'; import StreakBadge from '../../components/profile/StreakBadge'; import BorderPicker from '../../components/profile/BorderPicker'; +import TitlePickerModal from '../../components/profile/TitlePickerModal'; function buildBgGradient(isGod, isLegend, isElite, isBlood) { @@ -60,9 +64,11 @@ export default function ProfileScreen({ navigation }) { const { sessionLogs: logs, activityLogs, totalXP, loading: logsLoading } = useWorkoutLogs(); const { user, loading: profileLoading, refetch: refetchUser } = useUser(); const [borderPickerVisible, setBorderPickerVisible] = useState(false); + const [titlePickerVisible, setTitlePickerVisible] = useState(false); const { shapeId, colorId, selectShape, selectColor } = useAvatarFrame(); const { profileThemeId, godMode, trophyOverrides, reload: reloadDevSettings } = useDevSettings(); const { featuredIds, toggleFeatured, reload: reloadFeatured } = useFeaturedTrophies(); + const [equippedTitleMeta, setEquippedTitleMeta] = useState(null); // { label, rarity } | null // ─── Tutorial ───────────────────────────────────────────────────────────── const { @@ -84,6 +90,21 @@ export default function ProfileScreen({ navigation }) { }, [pendingChapterId, startChapter]), ); + // Résout le titre équipé (label + couleur de rareté) — rafraîchi à chaque + // retour sur l'écran et à chaque fermeture du sélecteur de titres (l'équipement + // a pu changer pendant que la popup était ouverte). + const loadEquippedTitle = useCallback(async () => { + try { + const res = await getTitles(); + const equipped = res.titles.find((t) => t.id === res.equippedTitle); + setEquippedTitleMeta(equipped ? { label: equipped.label, rarity: equipped.rarity } : null); + } catch (_) { + // best-effort — l'absence de titre affiché n'est pas bloquant. + } + }, []); + + useFocusEffect(useCallback(() => { loadEquippedTitle(); }, [loadEquippedTitle])); + // Enregistre le ScrollView et la fonction de re-mesure dans le contexte tutoriel. // Indispensable pour que scrollY: 260 de l'étape profile_vitrine fonctionne, // car sans ça la vitrine reste hors-écran au moment de la mesure. @@ -270,6 +291,8 @@ export default function ProfileScreen({ navigation }) { shapeId={shapeId} colorId={colorId} profileTheme={activeTheme} + titleLabel={equippedTitleMeta?.label} + titleColor={equippedTitleMeta ? RARITY_META[equippedTitleMeta.rarity]?.color : undefined} /> navigation && navigation.navigate('TrophyRoom')} accentColor={isElite ? rank.color : null} /> + setTitlePickerVisible(true)} + accentColor={isElite ? rank.color : null} + /> {/* ── Inventaire (bannière pleine largeur — trop à l'étroit dans quickActions) ── */} @@ -377,6 +406,11 @@ export default function ProfileScreen({ navigation }) { onSelectColor={selectColor} /> + { setTitlePickerVisible(false); loadEquippedTitle(); }} + /> + {activeChapterId === 'profile' && ( )} @@ -423,7 +457,7 @@ function QuickBtn({ icon, label, onPress, accentColor }) { return ( { haptics.success(); if (onPress) onPress(); }} activeOpacity={0.82} > @@ -449,9 +483,9 @@ const styles = StyleSheet.create({ heroWrapper: { position: 'relative' }, streakWrap: { marginTop: 10 }, - quickActions: { flexDirection: 'row', gap: 10, marginTop: 12 }, + quickActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 12 }, quickBtn: { - flex: 1, + width: '47.5%', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index b5a1c9e..4ea224f 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -43,7 +43,7 @@ import { syncBackendLevel, giveChests, generateMockSocial, giveAllItems, simulateChestsOpened, simulateReferral, simulateBirthday, simulateGroup, simulateActivityEvent, simulateStreakBreak, simulateShakeSelf, - simulateSearchableFriend, simulateLobbyInvite, + simulateSearchableFriend, simulateLobbyInvite, giveAllTitles, } from '../../services/debug.service'; const UNIT_WEIGHT_KEY = 'athly:unit:weight:v1'; @@ -283,10 +283,24 @@ export default function SettingsScreen({ navigation }) { } }, [refresh, showFeedback]); + // debugSetLevel (AsyncStorage local) + syncBackendLevel (user.level backend) + // dans la foulée : sans ça, le niveau simulé par God Mode divergeait + // silencieusement du niveau réellement stocké côté serveur — visible par + // exemple dans le sélecteur de titres, qui vérifie le niveau backend (comme + // le gating des coffres). syncBackendLevel échoue silencieusement en + // production (404, endpoint dev-only) : ne bloque jamais la simulation locale. + const setLevelEverywhere = async (n) => { + await debugSetLevel(n); + try { + await syncBackendLevel(n); + await refetchUser(); + } catch (_) { /* prod : endpoint indisponible, ignoré */ } + }; + const handleSetLevel = () => { const n = parseInt(targetLevel, 10); if (!targetLevel || isNaN(n) || n < 0 || n > 200) { showFeedback('Niveau invalide (0–200)'); return; } - runSim(() => debugSetLevel(n), `Niveau ${n} appliqué ✓`); + runSim(() => setLevelEverywhere(n), `Niveau ${n} appliqué ✓`); }; const handleAddXP = () => runSim(() => debugAddXP(1000), '+1000 XP injectés ✓'); const handleAddCustomXP = () => { @@ -295,10 +309,10 @@ export default function SettingsScreen({ navigation }) { runSim(() => debugAddXP(n), `+${n.toLocaleString('fr-FR')} XP injectés ✓`); setTargetXP(''); }; - const handlePlusLevel = () => runSim(() => debugSetLevel(level + 1), `Passage au niveau ${level + 1} ✓`); + const handlePlusLevel = () => runSim(() => setLevelEverywhere(level + 1), `Passage au niveau ${level + 1} ✓`); const handleMinusLevel = () => { if (level <= 0) { showFeedback('Déjà au niveau 0'); return; } - runSim(() => debugSetLevel(level - 1), `Retour au niveau ${level - 1} ✓`); + runSim(() => setLevelEverywhere(level - 1), `Retour au niveau ${level - 1} ✓`); }; const handleGenSessions = () => runSim(() => debugAddSessions(50), '50 séances injectées ✓'); const handleSimReps = () => runSim(() => debugSimulateReps(3000), '~3000 répétitions simulées ✓'); @@ -414,6 +428,22 @@ export default function SettingsScreen({ navigation }) { } }, [showFeedback]); + // Débloque tous les titres du catalogue (Section X) sans passer par leurs + // conditions réelles — teste le sélecteur en un clic. Bloqué en production (404). + const handleGiveAllTitles = useCallback(async () => { + try { + setSimLoading(true); + const res = await giveAllTitles(); + await refetchUser(); + showFeedback(res.message || 'Tous les titres débloqués ✓'); + } catch (e) { + const msg = e?.status === 404 ? 'Indisponible en production.' : (e?.data?.message || e?.message || 'inconnue'); + showFeedback('Erreur : ' + msg); + } finally { + setSimLoading(false); + } + }, [showFeedback, refetchUser]); + // Injecte 1 exemplaire de CHAQUE objet existant (consommables + cosmétiques // Uniques réclamables) pour tout tester en un clic. Bloqué en production (404). const handleGiveAllItems = useCallback(async () => { @@ -966,6 +996,17 @@ export default function SettingsScreen({ navigation }) { + {/* ── TITRES (Section X) ── */} + + + + + + Débloque directement les 17 titres du catalogue, sans passer par leurs + conditions réelles - teste le sélecteur de titres en un clic. + + + {/* ── SIMULATION DE SÉANCES ── */} diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index 44ee114..d86931d 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -10,6 +10,7 @@ import { Colors } from '../../constants/theme'; import { useToast } from '../../context/ToastContext'; import { getRank, xpToLevel } from '../../services/stats.service'; import { getFriendProfile, getMyGroup, shakeMember } from '../../services/social.service'; +import { RARITY_META } from '../../services/inventory.service'; import HeroLevelCard from '../../components/profile/HeroLevelCard'; import StreakBadge from '../../components/profile/StreakBadge'; @@ -168,6 +169,8 @@ export default function FriendProfileScreen({ route, navigation }) { totalActiveDays={profile.stats.totalActiveDays} shapeId={equippedFrame.shapeId} colorId={equippedFrame.colorId} + titleLabel={profile.equippedTitleInfo?.label} + titleColor={profile.equippedTitleInfo ? RARITY_META[profile.equippedTitleInfo.rarity]?.color : undefined} /> { + haptics.error(); setAbandonModalVisible(false); pendingNavActionRef.current = null; }, []); @@ -332,9 +333,8 @@ export default function WorkoutScreen({ route, navigation }) { // ─── TERMINER LA SÉANCE ─────────────────────────────────────────────────── const handleTerminate = useCallback(async () => { - try { - await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } catch (e) {} + // Clôture de séance : moment marquant → vibration lourde. + haptics.heavy(); if (isFinalizing || recapVisible || multiWaiting) return; diff --git a/front/src/services/debug.service.js b/front/src/services/debug.service.js index 4e48af8..b74bbd5 100644 --- a/front/src/services/debug.service.js +++ b/front/src/services/debug.service.js @@ -100,3 +100,12 @@ export async function simulateLobbyInvite() { const res = await API.post('/debug/godmode/simulate-lobby-invite'); return res.data; } + +// ─── Titres (Section X) ───────────────────────────────────────────────────── + +// Débloque directement tous les titres du catalogue (sans passer par leurs +// conditions réelles) — teste le sélecteur de titres en un clic. +export async function giveAllTitles() { + const res = await API.post('/debug/godmode/give-all-titles'); + return res.data; +} diff --git a/front/src/services/haptics.service.js b/front/src/services/haptics.service.js new file mode 100644 index 0000000..a74d11b --- /dev/null +++ b/front/src/services/haptics.service.js @@ -0,0 +1,43 @@ +import * as Haptics from 'expo-haptics'; + +// ─── Service Haptique centralisé (Section X) ─────────────────────────────────── +// Point d'entrée unique pour tous les retours physiques de l'app — évite de +// ré-écrire `try { Haptics.xxxAsync(...) } catch {}` à chaque appel. Web n'a +// pas de vibreur (expo-haptics y est un no-op silencieux), donc aucune garde +// de plateforme n'est nécessaire ici. +// +// Intensités volontairement poussées au maximum de ce qu'expose expo-haptics +// (retour jugé trop discret en usage réel) — pas de Light/selectionAsync seul, +// qui passent quasi inaperçus sur la plupart des téléphones : +// - success() : action réussie (série cochée, clic d'action, bouton rapide) — +// impact Medium, net et immédiat. +// - heavy() : moment marquant (clôture de séance, level-up, ouverture de +// coffre, déblocage de titre) — double impact Heavy. +// - error() : erreur / avertissement (pseudo injurieux, annulation d'abandon) — +// impact Heavy + triple vibration d'avertissement. +// - selection(): feedback de sélection neutre (toggle, tap sur une liste) — +// garde le primitif le plus léger, utilisé avec parcimonie. + +async function success() { + try { await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } catch (_) {} +} + +async function heavy() { + try { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + setTimeout(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy).catch(() => {}); }, 90); + } catch (_) {} +} + +async function error() { + try { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } catch (_) {} +} + +async function selection() { + try { await Haptics.selectionAsync(); } catch (_) {} +} + +export const haptics = { success, heavy, error, selection }; diff --git a/front/src/services/title.service.js b/front/src/services/title.service.js new file mode 100644 index 0000000..177bac3 --- /dev/null +++ b/front/src/services/title.service.js @@ -0,0 +1,14 @@ +import API from '../api/api'; + +// ─── Titres déblocables (Section X) ──────────────────────────────────────────── + +export async function getTitles() { + const res = await API.get('/profile/titles'); + return res.data; +} + +// titleId: string pour équiper, null pour déséquiper. +export async function equipTitle(titleId) { + const res = await API.post('/profile/equip-title', { titleId }); + return res.data; +} From 7bd0528a170c9fe31ad444c7de14336bfe67e82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 10 Jul 2026 12:17:10 +0200 Subject: [PATCH 22/31] chore(arch): refonte de la synchronisation XP/Niveau avec cliquet anti-regression et support offline cache --- back/controllers/user.controller.js | 18 +++ back/routes/user.routes.js | 6 +- back/services/user.service.js | 54 +++++++ back/tests/user.test.js | 105 +++++++++++++ back/utils/levelHelpers.js | 2 +- back/validators/user.validator.js | 7 + front/src/context/WorkoutLogsContext.js | 48 +++++- .../services/__tests__/xpSync.service.test.js | 138 ++++++++++++++++++ front/src/services/xpSync.service.js | 69 +++++++++ 9 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 front/src/services/__tests__/xpSync.service.test.js create mode 100644 front/src/services/xpSync.service.js diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index fe581f1..cc7de36 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -123,4 +123,22 @@ exports.registerPushToken = async (req, res, next) => { } catch (error) { next(error); } +}; + +/** + * SYNCHRONISER L'XP LOCALE + * Pousse l'XP totale accumulée localement (front, AsyncStorage-first) vers + * user.xp/level backend — Source de Vérité pour tout ce qui est gated côté + * serveur (coffres niveau 11+, conditions de titres). Voir user.service.js + * → syncXp pour le détail du ratchet anti-régression et du recalcul de niveau. + */ +exports.syncXp = async (req, res, next) => { + try { + const { xp } = req.body; + const result = await userService.syncXp(req.user.id, xp); + + res.status(200).json({ success: true, ...result }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 6b0a22c..fb62ae3 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const userController = require("../controllers/user.controller"); const auth = require("../middleware/auth.middleware"); const validate = require("../middleware/validate.middleware"); -const { updateProfile, updateFrame, updateShowcase, updateRecordsShowcase, registerPushToken } = require("../validators/user.validator"); +const { updateProfile, updateFrame, updateShowcase, updateRecordsShowcase, registerPushToken, syncXp } = require("../validators/user.validator"); /** * ROUTES UTILISATEURS PROTEGEES @@ -28,6 +28,10 @@ router.put("/me/records-showcase", auth, validate(updateRecordsShowcase), userCo // Enregistrer (ou effacer) le token Expo Push de l'appareil courant router.put("/me/push-token", auth, validate(registerPushToken), userController.registerPushToken); +// Synchronise l'XP totale locale (front) vers le backend — Source de Vérité +// pour le gating serveur (coffres niveau 11+, conditions de titres). +router.post("/me/sync-xp", auth, validate(syncXp), userController.syncXp); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/user.service.js b/back/services/user.service.js index 7a01105..a5e3d30 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -154,6 +154,60 @@ class UserService { return updatedUser; } + /** + * Synchronise l'XP totale calculée localement (front, AsyncStorage-first) + * vers user.xp/level backend — Source de Vérité pour tout ce qui est gated + * côté serveur (coffres niveau 11+, conditions de titres comme PERFORM_LEVEL_50). + * + * Sans ce point de synchro explicite, le niveau backend dérive silencieusement + * de celui vécu par le joueur : `finalizeWorkout` calcule sa PROPRE xp + * (anti-cheat serveur, à partir des exercices soumis) plutôt que d'adopter + * l'xp locale — les deux ledgers divergent avec le temps. + * + * Sécurité : XP en lecture seule ratchet (ne redescend jamais) — un sync + * redondant ou tardif ne peut jamais effacer une progression déjà actée. + * Bornée à xpForLevel(MAX_LEVEL) pour rejeter tout payload absurde (la + * validation Joi en amont ne fait qu'un garde-fou grossier). + * + * @param {string} userId + * @param {number} submittedXp — XP cumulée locale (jamais un delta) + * @returns {{ level, xp, rank, newlyUnlockedTitles }} + */ + async syncXp(userId, submittedXp) { + const { levelFromXP, getRankForLevel, xpForLevel, MAX_LEVEL } = require('../utils/levelHelpers'); + + const user = await User.findById(userId).select('xp level rank'); + if (!user) throw new Error('Utilisateur non trouvé.'); + + const clampedXp = Math.max(0, Math.min(submittedXp, xpForLevel(MAX_LEVEL))); + const nextXp = Math.max(user.xp || 0, clampedXp); + + let newlyUnlockedTitles = []; + + if (nextXp !== user.xp) { + user.xp = nextXp; + user.level = levelFromXP(nextXp); + user.rank = getRankForLevel(user.level); + await user.save(); + + // Require tardif : évite le cycle user.service ↔ title.controller au + // chargement des modules (voir même idiome dans reward.controller.js). + try { + const { checkAndUnlockTitles } = require('../controllers/title.controller'); + newlyUnlockedTitles = await checkAndUnlockTitles(userId); + } catch (_) { + // best-effort — la synchro XP elle-même ne doit jamais échouer pour ça. + } + } + + return { + level: user.level, + xp: user.xp, + rank: user.rank, + newlyUnlockedTitles, + }; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/user.test.js b/back/tests/user.test.js index b1632e0..b073c3a 100644 --- a/back/tests/user.test.js +++ b/back/tests/user.test.js @@ -75,4 +75,109 @@ describe('User API (Routes Protégées)', () => { expect(res.statusCode).toBe(401); }); }); + + describe('POST /api/users/me/sync-xp — syncXp (Section X)', () => { + const { xpForLevel } = require('../utils/levelHelpers'); + + beforeEach(async () => { + await User.updateOne( + { email: 'user@test.fr' }, + { $set: { xp: 0, level: 0, rank: 'Novice', unlockedTitles: [] } }, + ); + }); + + it('✅ Recalcule level/rank côté serveur à partir de xp', async () => { + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(12) }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(12); + expect(res.body.rank).toBe('Initié'); + expect(res.body.xp).toBe(xpForLevel(12)); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.level).toBe(12); + expect(user.xp).toBe(xpForLevel(12)); + }); + + it("✅ Ratchet : un xp inférieur à l'existant ne fait jamais redescendre le niveau", async () => { + await User.updateOne({ email: 'user@test.fr' }, { $set: { xp: xpForLevel(20), level: 20, rank: 'Initié' } }); + + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(5) }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(20); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.level).toBe(20); + }); + + it('✅ Idempotent : renvoyer la même valeur ne déclenche pas de re-déblocage', async () => { + await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + const second = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + expect(second.body.newlyUnlockedTitles).not.toContain('PERFORM_LEVEL_10'); + }); + + it('✅ Débloque les titres dont la condition de niveau est franchie', async () => { + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + expect(res.body.newlyUnlockedTitles).toContain('PERFORM_LEVEL_10'); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.unlockedTitles).toContain('PERFORM_LEVEL_10'); + }); + + it('✅ Clampe un xp aberrant (au-delà du niveau 200) sans planter', async () => { + // En dessous du plafond Joi (garde-fou grossier, 100M) mais bien + // au-delà de xpForLevel(200) (~1.72M) — exerce le clamp du service. + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: 50000000 }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(200); + }); + + it('❌ 400 si xp négatif, non numérique ou manquant', async () => { + const negative = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: -5 }); + expect(negative.statusCode).toBe(400); + + const invalid = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: 'abc' }); + expect(invalid.statusCode).toBe(400); + + const missing = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({}); + expect(missing.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/users/me/sync-xp').send({ xp: 100 }); + expect(res.statusCode).toBe(401); + }); + }); }); \ No newline at end of file diff --git a/back/utils/levelHelpers.js b/back/utils/levelHelpers.js index 2c25388..557652c 100644 --- a/back/utils/levelHelpers.js +++ b/back/utils/levelHelpers.js @@ -51,4 +51,4 @@ function getRankForLevel(level) { return match ? match.rank : 'Novice'; } -module.exports = { xpForLevel, levelFromXP, getRankForLevel }; +module.exports = { xpForLevel, levelFromXP, getRankForLevel, MAX_LEVEL }; diff --git a/back/validators/user.validator.js b/back/validators/user.validator.js index 1703eff..88ec6de 100644 --- a/back/validators/user.validator.js +++ b/back/validators/user.validator.js @@ -35,6 +35,13 @@ const userSchemas = { // null explicite = désenregistrement (permissions révoquées côté client) pushToken: Joi.string().max(200).allow(null).required(), }), + + // Synchronisation XP local → backend (voir user.service.js → syncXp). + // Borne haute large mais finie : le clamp fin (xpForLevel(200)) est fait + // côté service, cette borne Joi n'est qu'un garde-fou anti-payload absurde. + syncXp: Joi.object({ + xp: Joi.number().integer().min(0).max(100000000).required(), + }), }; module.exports = userSchemas; \ No newline at end of file diff --git a/front/src/context/WorkoutLogsContext.js b/front/src/context/WorkoutLogsContext.js index 204e358..8b0f178 100644 --- a/front/src/context/WorkoutLogsContext.js +++ b/front/src/context/WorkoutLogsContext.js @@ -1,5 +1,7 @@ -import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; +import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { AppState } from 'react-native'; import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog, addBonusXpLog } from '../services/stats.service'; +import { syncXp, retryPendingXpSync } from '../services/xpSync.service'; // Context global pour l'historique des séances finalisées (logs). // Source de vérité unique pour StatsScreen, ExerciseStatsScreen, ProfileScreen. @@ -10,6 +12,7 @@ export function WorkoutLogsProvider({ children }) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const appState = useRef(AppState.currentState); const refresh = useCallback(async () => { setLoading(true); @@ -17,6 +20,9 @@ export function WorkoutLogsProvider({ children }) { try { const list = await listLogs(); setItems(list); + // Check-up de cohérence au démarrage (Section X) : pousse le total XP + // local vers le backend, fire-and-forget — voir xpSync.service.js. + syncXp(totalCumulativeXP(list)); } catch (e) { setError(e && e.message ? e.message : 'Erreur de chargement'); } finally { @@ -28,10 +34,31 @@ export function WorkoutLogsProvider({ children }) { refresh(); }, [refresh]); + // Pas de détection réseau active dans ce projet (pas de NetInfo) : le retour + // au premier plan de l'app est le proxy le plus proche de "le réseau est + // peut-être revenu" — ne retente que si un sync précédent a échoué + // (isXpSynced: false), pour ne pas spammer l'API à chaque changement d'onglet. + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextState) => { + if (appState.current.match(/inactive|background/) && nextState === 'active') { + retryPendingXpSync(totalCumulativeXP(items)); + } + appState.current = nextState; + }); + return () => subscription.remove(); + }, [items]); + const create = useCallback(async (log) => { const item = await addLog(log); // Insertion en tête (logs triés par date desc) - setItems((prev) => [item, ...prev]); + let newTotal; + setItems((prev) => { + const next = [item, ...prev]; + newTotal = totalCumulativeXP(next); + return next; + }); + // Événement clé (Section X) : fin de séance (solo ou Multi) — sync fire-and-forget. + syncXp(newTotal); return item; }, []); @@ -63,7 +90,13 @@ export function WorkoutLogsProvider({ children }) { const addRitual = useCallback(async (ritualId, ritualLabel, durationSeconds, xpEarned) => { const item = await addRitualLog(ritualId, ritualLabel, durationSeconds, xpEarned); if (!item) return null; - setItems((prev) => [item, ...prev]); + let newTotal; + setItems((prev) => { + const next = [item, ...prev]; + newTotal = totalCumulativeXP(next); + return next; + }); + syncXp(newTotal); return item; }, []); @@ -73,7 +106,14 @@ export function WorkoutLogsProvider({ children }) { const addBonusXp = useCallback(async (source, xpEarned) => { const item = await addBonusXpLog(source, xpEarned); if (!item) return null; - setItems((prev) => [item, ...prev]); + let newTotal; + setItems((prev) => { + const next = [item, ...prev]; + newTotal = totalCumulativeXP(next); + return next; + }); + // Événement clé (Section X) : bonus XP (ex: bonus de groupe Multi) — sync fire-and-forget. + syncXp(newTotal); return item; }, []); diff --git a/front/src/services/__tests__/xpSync.service.test.js b/front/src/services/__tests__/xpSync.service.test.js new file mode 100644 index 0000000..6419161 --- /dev/null +++ b/front/src/services/__tests__/xpSync.service.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// Mocks manuels : ce fichier tourne sous testEnvironment 'node' (voir +// jest.config.js — pas d'environnement React Native), donc AsyncStorage et +// l'instance axios (../api/api) doivent être entièrement remplacés, jamais +// réellement importés (ils dépendent de bindings natifs indisponibles ici). +const mockGetItem = jest.fn(); +const mockSetItem = jest.fn().mockResolvedValue(undefined); +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: (...args) => mockGetItem(...args), + setItem: (...args) => mockSetItem(...args), +})); + +const mockPost = jest.fn(); +jest.mock('../../api/api', () => ({ + __esModule: true, + default: { post: (...args) => mockPost(...args) }, +})); + +const XP_SYNCED_FLAG_KEY = 'athly:xp:isXpSynced:v1'; + +describe('xpSync.service — synchronisation XP locale → backend (Section X)', () => { + let syncXp; + let retryPendingXpSync; + + beforeEach(() => { + // lastSyncedXp est un state de MODULE (mémoire) — reset complet entre + // chaque test pour ne jamais laisser un test polluer le suivant. + jest.resetModules(); + jest.clearAllMocks(); + mockSetItem.mockResolvedValue(undefined); + ({ syncXp, retryPendingXpSync } = require('../xpSync.service')); + }); + + describe('syncXp — mode connecté', () => { + it('✅ Envoie le total XP arrondi et marque isXpSynced à true', async () => { + mockPost.mockResolvedValue({ data: { success: true, level: 12, xp: 1800, rank: 'Initié', newlyUnlockedTitles: [] } }); + + const result = await syncXp(1799.6); + + expect(mockPost).toHaveBeenCalledWith('/users/me/sync-xp', { xp: 1800 }); + expect(result).toEqual({ success: true, level: 12, xp: 1800, rank: 'Initié', newlyUnlockedTitles: [] }); + expect(mockSetItem).toHaveBeenCalledWith(XP_SYNCED_FLAG_KEY, 'true'); + }); + + it("✅ Renvoie les titres nouvellement débloqués par ce gain d'XP", async () => { + mockPost.mockResolvedValue({ data: { success: true, level: 50, xp: 200000, rank: 'Compétiteur', newlyUnlockedTitles: ['PERFORM_LEVEL_50'] } }); + + const result = await syncXp(200000); + + expect(result.newlyUnlockedTitles).toContain('PERFORM_LEVEL_50'); + }); + + it('✅ Ne rappelle pas l\'API si la valeur est identique au dernier sync réussi (dédup mémoire)', async () => { + mockPost.mockResolvedValue({ data: { success: true, level: 5, xp: 500, rank: 'Novice', newlyUnlockedTitles: [] } }); + + await syncXp(500); + await syncXp(500); + + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + it('✅ Rappelle bien l\'API si la valeur a changé depuis le dernier sync', async () => { + mockPost.mockResolvedValue({ data: { success: true, level: 5, xp: 500, rank: 'Novice', newlyUnlockedTitles: [] } }); + await syncXp(500); + + mockPost.mockResolvedValue({ data: { success: true, level: 6, xp: 600, rank: 'Novice', newlyUnlockedTitles: [] } }); + await syncXp(600); + + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('❌ xp négatif ou non-fini : ne fait aucun appel réseau', async () => { + await syncXp(-5); + await syncXp(NaN); + await syncXp(undefined); + + expect(mockPost).not.toHaveBeenCalled(); + }); + }); + + describe('syncXp — mode déconnecté (offline)', () => { + it("✅ Échec réseau : renvoie null et marque isXpSynced à false (sans lever d'exception)", async () => { + mockPost.mockRejectedValue(new Error('Network Error')); + + const result = await syncXp(1000); + + expect(result).toBeNull(); + expect(mockSetItem).toHaveBeenCalledWith(XP_SYNCED_FLAG_KEY, 'false'); + }); + + it("✅ Un échec n'active pas la dédup mémoire — le prochain appel retente vraiment", async () => { + mockPost.mockRejectedValueOnce(new Error('Network Error')); + mockPost.mockResolvedValueOnce({ data: { success: true, level: 3, xp: 300, rank: 'Novice', newlyUnlockedTitles: [] } }); + + const first = await syncXp(300); + const second = await syncXp(300); + + expect(first).toBeNull(); + expect(second).not.toBeNull(); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + }); + + describe('retryPendingXpSync — retente au retour réseau / prochain démarrage', () => { + it('✅ Retente si isXpSynced === "false"', async () => { + mockGetItem.mockResolvedValue('false'); + mockPost.mockResolvedValue({ data: { success: true, level: 4, xp: 400, rank: 'Novice', newlyUnlockedTitles: [] } }); + + await retryPendingXpSync(400); + + expect(mockPost).toHaveBeenCalledWith('/users/me/sync-xp', { xp: 400 }); + }); + + it('❌ Ne retente pas si isXpSynced === "true" (déjà à jour)', async () => { + mockGetItem.mockResolvedValue('true'); + + await retryPendingXpSync(400); + + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("❌ Ne retente pas si le flag n'a jamais été écrit (premier lancement)", async () => { + mockGetItem.mockResolvedValue(null); + + await retryPendingXpSync(400); + + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('✅ Best-effort : une erreur AsyncStorage ne lève jamais', async () => { + mockGetItem.mockRejectedValue(new Error('storage unavailable')); + + await expect(retryPendingXpSync(400)).resolves.toBeUndefined(); + expect(mockPost).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/front/src/services/xpSync.service.js b/front/src/services/xpSync.service.js new file mode 100644 index 0000000..3abf1b2 --- /dev/null +++ b/front/src/services/xpSync.service.js @@ -0,0 +1,69 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import API from '../api/api'; + +// ─── Synchronisation XP locale → backend (Section X) ─────────────────────────── +// L'app reste local-first pour l'XP (AsyncStorage, réactif, jamais bloqué par +// le réseau) — mais le backend doit rester la Source de Vérité pour tout ce +// qui est gated côté serveur (coffres niveau 11+, conditions de titres comme +// "Le Titan"). Ce service pousse silencieusement le total XP local vers +// POST /api/users/me/sync-xp à chaque événement clé (voir WorkoutLogsContext.js +// et App.js) ; le backend applique un ratchet (n'accepte jamais une baisse), +// donc rejouer ce sync n'importe quand est toujours sans danger. +// +// Pas de détection réseau active (pas de NetInfo dans ce projet) : en cas +// d'échec, isXpSynced passe à false et CHAQUE prochain événement clé (dont le +// prochain démarrage de l'app) retente — cohérent avec le reste de l'app, qui +// n'observe jamais la connectivité activement (voir api.js, mode dégradé +// "tente et dégrade" plutôt que "surveille puis agit"). + +const XP_SYNCED_FLAG_KEY = 'athly:xp:isXpSynced:v1'; + +// Garde en mémoire (pas AsyncStorage) la dernière valeur réellement envoyée +// avec succès cette session — évite de spammer l'API à chaque log ajouté +// pendant une même séance si l'xp n'a pas bougé entre deux appels. +let lastSyncedXp = null; + +/** + * Pousse `totalXP` (cumul local total, jamais un delta) vers le backend. + * Best-effort : ne lève jamais, best pour être appelée en fire-and-forget + * depuis n'importe quel point d'action sans `await` bloquant l'UI. + * + * @param {number} totalXP + * @returns {Promise<{level:number, xp:number, rank:string, newlyUnlockedTitles:string[]}|null>} + */ +export async function syncXp(totalXP) { + if (!Number.isFinite(totalXP) || totalXP < 0) return null; + const rounded = Math.round(totalXP); + if (rounded === lastSyncedXp) return null; // rien de nouveau depuis le dernier sync réussi + + try { + const res = await API.post('/users/me/sync-xp', { xp: rounded }); + lastSyncedXp = rounded; + AsyncStorage.setItem(XP_SYNCED_FLAG_KEY, 'true').catch(() => {}); + return res.data ?? null; + } catch (_) { + AsyncStorage.setItem(XP_SYNCED_FLAG_KEY, 'false').catch(() => {}); + return null; + } +} + +/** + * À appeler au démarrage de l'app (check-up de cohérence) : relit le flag + * laissé par un échec précédent et retente si besoin. Comme `syncXp` est de + * toute façon appelée à chaque événement clé, ceci n'est qu'un filet de + * sécurité supplémentaire pour le cas "aucune action XP depuis le retour du + * réseau" (ex: l'utilisateur rouvre juste l'app sans s'entraîner). + * + * @param {number} currentTotalXP — total XP local ACTUEL (pas la valeur figée + * au moment de l'échec — le ratchet backend prend de toute façon le max). + */ +export async function retryPendingXpSync(currentTotalXP) { + try { + const flag = await AsyncStorage.getItem(XP_SYNCED_FLAG_KEY); + if (flag === 'false') { + await syncXp(currentTotalXP); + } + } catch (_) { + // best-effort + } +} From 0375c9de0da0a8bf27927b9e0ae293e7fe8e4612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 10 Jul 2026 16:39:58 +0200 Subject: [PATCH 23/31] feat(tutorial): refonte complete du guide interactif V2 avec progression dynamique par chapitres et spotlights --- back/controllers/user.controller.js | 19 ++ back/middleware/rateLimit.middleware.js | 11 +- back/models/User.js | 8 + back/routes/user.routes.js | 3 + back/services/user.service.js | 18 ++ back/tests/user.test.js | 54 ++++++ .../components/tutorial/TutorialOverlay.js | 117 ++++++++---- front/src/context/TutorialContext.js | 18 +- .../data/__tests__/tutorialChapters.test.js | 167 ++++++++++++++++++ front/src/data/tutorialChapters.js | 112 +++++++++++- front/src/screens/Home/HomeScreen.js | 15 +- front/src/screens/Profile/InventoryScreen.js | 37 +++- front/src/screens/Profile/SettingsScreen.js | 4 +- front/src/screens/Social/SocialScreen.js | 26 ++- front/src/screens/Stats/StatsScreen.js | 80 +++++++-- front/src/services/onboarding.service.js | 10 ++ 16 files changed, 622 insertions(+), 77 deletions(-) create mode 100644 front/src/data/__tests__/tutorialChapters.test.js create mode 100644 front/src/services/onboarding.service.js diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index fe581f1..c8a7a8a 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -123,4 +123,23 @@ exports.registerPushToken = async (req, res, next) => { } catch (error) { next(error); } +}; + +/** + * MARQUER LE TUTORIEL COMME TERMINÉ + * Appelé par le front à la fin (ou au skip) du tutoriel interactif. + * Idempotent — voir user.service.js → completeOnboarding. + */ +exports.completeOnboarding = async (req, res, next) => { + try { + const user = await userService.completeOnboarding(req.user.id); + + res.status(200).json({ + success: true, + message: "Onboarding terminé.", + hasCompletedOnboarding: user.hasCompletedOnboarding, + }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/back/middleware/rateLimit.middleware.js b/back/middleware/rateLimit.middleware.js index c2ce31e..a0c1565 100644 --- a/back/middleware/rateLimit.middleware.js +++ b/back/middleware/rateLimit.middleware.js @@ -4,14 +4,19 @@ const { rateLimit, ipKeyGenerator } = require('express-rate-limit'); const config = require('../config/env'); // Les tests Jest enchaînent des centaines de requêtes : on désactive le -// rate-limiting en environnement de test uniquement. -const isTest = config.nodeEnv === 'test'; +// rate-limiting en environnement de test uniquement. Détection via +// JEST_WORKER_ID (défini par Jest dans chaque worker) plutôt que NODE_ENV : +// certains tests basculent temporairement config.nodeEnv vers 'production' +// (pour vérifier les routes devOnly), ce qui réactivait par erreur le limiter +// au milieu de la suite et provoquait des 429 non-déterministes. Évalué à +// chaque requête (pas figé au chargement du module). +const isTestEnv = () => process.env.JEST_WORKER_ID !== undefined || config.nodeEnv === 'test'; const standardOptions = { windowMs: 15 * 60 * 1000, standardHeaders: true, // RateLimit-* headers pour les clients legacyHeaders: false, - skip: () => isTest, + skip: () => isTestEnv(), message: { success: false, status: 429, diff --git a/back/models/User.js b/back/models/User.js index 2197eb8..e892440 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -181,6 +181,14 @@ const UserSchema = new mongoose.Schema( // ex. "FRAME_SHAPE_DRAGONFANG", "FRAME_COLOR_BLOODSANG", "THEME_BLOODSANG". unlockedCosmetics: { type: [String], default: [] }, + // ── Onboarding / Tutoriel interactif ────────────────────────────────────── + // Passe à true quand l'utilisateur termine (ou passe) le tutoriel interactif. + // Doublé côté client dans AsyncStorage (source immédiate du déclenchement), + // ce flag backend est la vérité inter-appareils : une réinstallation ou une + // connexion sur un nouvel appareil ne re-déclenche pas le tutoriel + // (voir TutorialContext.js → reconcileWithServer). + hasCompletedOnboarding: { type: Boolean, default: false }, + // ── Présence & notifications push ───────────────────────────────────────── // Token Expo Push (ExponentPushToken[...]) enregistré par le front après // acceptation des permissions — voir push.service.js. null = aucun appareil diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 6b0a22c..c901d6b 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -28,6 +28,9 @@ router.put("/me/records-showcase", auth, validate(updateRecordsShowcase), userCo // Enregistrer (ou effacer) le token Expo Push de l'appareil courant router.put("/me/push-token", auth, validate(registerPushToken), userController.registerPushToken); +// Marquer le tutoriel/onboarding comme terminé — pas de body (idempotent) +router.post("/me/complete-onboarding", auth, userController.completeOnboarding); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/user.service.js b/back/services/user.service.js index 7a01105..a947dcc 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -154,6 +154,24 @@ class UserService { return updatedUser; } + /** + * Marque le tutoriel/onboarding comme terminé pour cet utilisateur. + * Idempotent : rejouer ne change rien une fois le flag à true. Le client + * appelle ceci en best-effort à la fin (ou au skip) du tutoriel — la vérité + * immédiate reste AsyncStorage, ce flag backend sert la cohérence + * inter-appareils (voir TutorialContext.js). + * @param {string} userId + */ + async completeOnboarding(userId) { + const updatedUser = await User.findByIdAndUpdate( + userId, + { $set: { hasCompletedOnboarding: true } }, + { new: true }, + ).select('hasCompletedOnboarding'); + if (!updatedUser) throw new Error('Utilisateur non trouvé.'); + return updatedUser; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/user.test.js b/back/tests/user.test.js index b1632e0..209ce01 100644 --- a/back/tests/user.test.js +++ b/back/tests/user.test.js @@ -75,4 +75,58 @@ describe('User API (Routes Protégées)', () => { expect(res.statusCode).toBe(401); }); }); + + describe('POST /api/users/me/complete-onboarding — completeOnboarding (Tutoriel)', () => { + beforeEach(async () => { + await User.updateOne({ email: 'user@test.fr' }, { $set: { hasCompletedOnboarding: false } }); + }); + + it('✅ hasCompletedOnboarding est false par défaut', async () => { + const res = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + + expect(res.body.user.hasCompletedOnboarding).toBe(false); + }); + + it('✅ Passe le flag à true et le persiste en base', async () => { + const res = await request(app) + .post('/api/users/me/complete-onboarding') + .set('Authorization', `Bearer ${token}`); + + expect(res.statusCode).toBe(200); + expect(res.body.hasCompletedOnboarding).toBe(true); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.hasCompletedOnboarding).toBe(true); + }); + + it('✅ Idempotent : rejouer laisse le flag à true', async () => { + await request(app) + .post('/api/users/me/complete-onboarding') + .set('Authorization', `Bearer ${token}`); + const second = await request(app) + .post('/api/users/me/complete-onboarding') + .set('Authorization', `Bearer ${token}`); + + expect(second.statusCode).toBe(200); + expect(second.body.hasCompletedOnboarding).toBe(true); + }); + + it('✅ getMe reflète le flag une fois terminé', async () => { + await request(app) + .post('/api/users/me/complete-onboarding') + .set('Authorization', `Bearer ${token}`); + + const me = await request(app) + .get('/api/users/me') + .set('Authorization', `Bearer ${token}`); + expect(me.body.user.hasCompletedOnboarding).toBe(true); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/users/me/complete-onboarding'); + expect(res.statusCode).toBe(401); + }); + }); }); \ No newline at end of file diff --git a/front/src/components/tutorial/TutorialOverlay.js b/front/src/components/tutorial/TutorialOverlay.js index 75de27b..91a7f0d 100644 --- a/front/src/components/tutorial/TutorialOverlay.js +++ b/front/src/components/tutorial/TutorialOverlay.js @@ -1,25 +1,29 @@ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect, useRef, useCallback, useState } from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated, useWindowDimensions, Platform, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTutorial } from '../../context/TutorialContext'; +import { CHAPTER_IDS } from '../../data/tutorialChapters'; +import { haptics } from '../../services/haptics.service'; import { Colors } from '../../constants/theme'; // Padding visuel autour du spotlight (léger, ne perturbe pas les coordonnées) const SPOTLIGHT_PADDING = 8; // Marge gauche/droite du tooltip const TOOLTIP_MARGIN = 16; -// Hauteur max estimée du tooltip — sert uniquement au calcul de placement +// Hauteur estimée du tooltip AVANT sa première mesure (remplacée par la vraie +// hauteur via onLayout dès le premier rendu) const TOOLTIP_HEIGHT_EST = 220; -// Seuil vertical (px) : si pageY de l'élément < THRESHOLD → tooltip en-dessous -// sinon → tooltip au-dessus (algorithme strict demandé, indépendant de `position`) -const ELEMENT_Y_THRESHOLD = 250; // Écart gap entre le bord du spotlight et le tooltip const TOOLTIP_GAP = 15; // Marge de sécurité minimale par rapport aux bords de l'écran const SCREEN_SAFE = 12; +// Insets approximés : barre de statut/encoche en haut, indicateur/onglets en bas. +// Gardent le tooltip lisible et cliquable hors des zones système. +const SAFE_INSET_TOP = 44; +const SAFE_INSET_BOTTOM = 28; const GLOW_COLOR = 'rgba(254,116,57,0.55)'; // ─── ProgressDots ───────────────────────────────────────────────────────────── @@ -51,11 +55,13 @@ const dots = StyleSheet.create({ // ─── ChapterBadge ───────────────────────────────────────────────────────────── -function ChapterBadge({ chapter }) { +function ChapterBadge({ chapter, index, total }) { return ( - {chapter.subtitle} · {chapter.title} + Chapitre {index}/{total} + + {chapter.title} ); } @@ -63,12 +69,14 @@ function ChapterBadge({ chapter }) { const badge = StyleSheet.create({ wrap: { flexDirection: 'row', alignItems: 'center', gap: 5, - alignSelf: 'flex-start', + alignSelf: 'flex-start', maxWidth: '100%', backgroundColor: 'rgba(254,116,57,0.12)', borderWidth: 1, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4, marginBottom: 10, }, - text: { color: Colors.primary, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 }, + counter: { color: Colors.primary, fontSize: 10, fontWeight: '900', letterSpacing: 0.5 }, + sep: { width: 3, height: 3, borderRadius: 2, backgroundColor: Colors.primary, opacity: 0.6 }, + text: { color: Colors.primary, fontSize: 10, fontWeight: '700', letterSpacing: 0.3, flexShrink: 1 }, }); // ─── TutorialOverlay ────────────────────────────────────────────────────────── @@ -87,6 +95,10 @@ export default function TutorialOverlay({ navigation }) { const slideY = useRef(new Animated.Value(24)).current; const opacity = useRef(new Animated.Value(0)).current; + // Hauteur réelle du tooltip, mesurée au rendu (onLayout). Sert au calcul de + // placement : avec une estimation fixe, un texte long débordait sous l'écran + // et le bouton "Suivant" devenait inatteignable. + const [tooltipH, setTooltipH] = useState(TOOLTIP_HEIGHT_EST); const animateIn = useCallback(() => { slideY.setValue(24); @@ -114,36 +126,49 @@ export default function TutorialOverlay({ navigation }) { h: targetRect.height + SPOTLIGHT_PADDING * 2, } : null; - // ─── Positionnement du tooltip (algorithme strict basé sur pageY) ───────── + // ─── Positionnement du tooltip (espace réel + intention du design) ──────── // - // Règle absolue : - // • Pas de cible OU position === 'center' → centré verticalement - // • pageY de l'élément < ELEMENT_Y_THRESHOLD (250 px) - // → tooltip EN-DESSOUS : top = pageY + height + GAP - // • pageY ≥ ELEMENT_Y_THRESHOLD - // → tooltip AU-DESSUS : top = pageY - TOOLTIP_HEIGHT_EST - GAP + // Règles : + // • Pas de cible OU position === 'center' → centré verticalement. + // • Sinon on respecte le côté voulu par le design (position 'top' → tooltip + // AU-DESSUS de la cible, 'bottom' → EN-DESSOUS), MAIS on bascule sur + // l'autre côté s'il n'y a pas la place (cible trop haute/basse ou trop + // grande). On mesure la hauteur RÉELLE du tooltip (tooltipH) pour ne + // jamais le laisser déborder hors de l'écran : sans ça, un texte long + // poussait le bouton "Suivant" sous le bord bas, illisible et incliquable. // - // Les coordonnées utilisées sont celles de l'élément brut (targetRect), - // pas les valeurs paddées du spotlight, pour un placement pixel-perfect. + // Les bornes verticales tiennent compte des marges hautes/basses de sécurité + // (SCREEN_SAFE + insets approximés) pour rester sous la barre d'onglets. const tooltipStyle = (() => { const base = { position: 'absolute', left: TOOLTIP_MARGIN, right: TOOLTIP_MARGIN }; + const safeTop = SCREEN_SAFE + SAFE_INSET_TOP; + const safeBottom = H - SCREEN_SAFE - SAFE_INSET_BOTTOM; + const maxTop = Math.max(safeTop, safeBottom - tooltipH); + if (!hasSpot || activeStep.position === 'center') { - return { ...base, top: Math.max(SCREEN_SAFE, H / 2 - TOOLTIP_HEIGHT_EST / 2) }; + const centered = (safeTop + safeBottom) / 2 - tooltipH / 2; + return { ...base, top: Math.min(Math.max(safeTop, centered), maxTop) }; } - const elemY = targetRect.y; // pageY réel de l'élément (coordonnée native) - const elemH = targetRect.height; // hauteur réelle de l'élément - - if (elemY < ELEMENT_Y_THRESHOLD) { - // Élément dans la partie HAUTE de l'écran → tooltip en-dessous - const topBelow = elemY + elemH + TOOLTIP_GAP; - return { ...base, top: Math.min(topBelow, H - TOOLTIP_HEIGHT_EST - SCREEN_SAFE) }; - } else { - // Élément dans la partie BASSE/MILIEU → tooltip au-dessus - const topAbove = elemY - TOOLTIP_HEIGHT_EST - TOOLTIP_GAP; - return { ...base, top: Math.max(SCREEN_SAFE, topAbove) }; - } + const spotTop = targetRect.y - SPOTLIGHT_PADDING; + const spotBottom = targetRect.y + targetRect.height + SPOTLIGHT_PADDING; + const need = tooltipH + TOOLTIP_GAP; + const roomAbove = spotTop - safeTop; + const roomBelow = safeBottom - spotBottom; + + // Côté souhaité par le design, conservé tant qu'il y a la place ; à défaut + // on prend le côté le plus spacieux (jamais par-dessus la cible). + const prefersAbove = activeStep.position === 'top'; + const placeAbove = prefersAbove + ? (roomAbove >= need || roomAbove >= roomBelow) + : (roomBelow >= need ? false : roomAbove > roomBelow); + + const rawTop = placeAbove + ? spotTop - TOOLTIP_GAP - tooltipH + : spotBottom + TOOLTIP_GAP; + + return { ...base, top: Math.min(Math.max(safeTop, rawTop), maxTop) }; })(); const chapterColor = Colors.primary; @@ -151,7 +176,23 @@ export default function TutorialOverlay({ navigation }) { const isEndCard = !!activeStep.isLast; const isAction = !!activeStep.actionRequired; - const handleNext = () => nextStep(navigation); + // Position globale du chapitre courant (pour l'indicateur "Chapitre X/N"). + const chapterIndex = CHAPTER_IDS.indexOf(activeChapter.id) + 1; + const totalChapters = CHAPTER_IDS.length; + + const handleNext = () => { + // Retour haptique : lourd et marquant à la toute fin du tutoriel, + // léger sur une simple avance (étape ou chapitre suivant). + if (isEndCard) haptics.heavy(); + else haptics.selection(); + nextStep(navigation); + }; + + const handleSkip = () => { + // "Passer" ferme le tutoriel : léger retour haptique de confirmation. + haptics.selection(); + dismiss(); + }; const nextLabel = isEndCard ? 'Terminer le tutoriel ✓' : isLastStep ? 'Chapitre suivant' @@ -190,8 +231,14 @@ export default function TutorialOverlay({ navigation }) { { + const h = e.nativeEvent.layout.height; + // Ne met à jour que sur variation nette (>1px) pour éviter les + // boucles de re-rendu dues aux arrondis sub-pixel. + if (h > 0 && Math.abs(h - tooltipH) > 1) setTooltipH(h); + }} > - + {activeStep.title} {activeStep.body} @@ -206,7 +253,7 @@ export default function TutorialOverlay({ navigation }) { ) : ( - + Passer + Passer le tutoriel )} diff --git a/front/src/context/TutorialContext.js b/front/src/context/TutorialContext.js index 70d7bd8..a6384b4 100644 --- a/front/src/context/TutorialContext.js +++ b/front/src/context/TutorialContext.js @@ -1,6 +1,7 @@ import React, { createContext, useState, useContext, useCallback, useEffect, useRef } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { TUTORIAL_CHAPTERS, CHAPTER_MAP, CHAPTER_IDS } from '../data/tutorialChapters'; +import { completeOnboarding } from '../services/onboarding.service'; const DONE_KEY = 'athly:tutorial:completed:v1'; const PENDING_KEY = 'athly:tutorial:pendingChapter:v1'; @@ -110,6 +111,9 @@ export function TutorialProvider({ children }) { AsyncStorage.setItem(DONE_KEY, 'true'), AsyncStorage.removeItem(PENDING_KEY), ]); + // Persistance backend (best-effort, jamais bloquante) — cohérence + // inter-appareils du flag hasCompletedOnboarding. + completeOnboarding().catch(() => {}); return; } @@ -151,6 +155,18 @@ export function TutorialProvider({ children }) { AsyncStorage.setItem(DONE_KEY, 'true'), AsyncStorage.removeItem(PENDING_KEY), ]); + // "Passer le tutoriel" compte aussi comme terminé côté backend (best-effort). + completeOnboarding().catch(() => {}); + }, []); + + // Réconciliation avec le flag backend (hasCompletedOnboarding) une fois le + // profil chargé : si le serveur dit "déjà fait" alors que ce nouvel appareil + // n'a pas encore le flag local, on marque terminé pour ne pas re-déclencher + // le tutoriel. Ne fait jamais l'inverse (le serveur ne peut pas "ré-ouvrir"). + const reconcileWithServer = useCallback(async (serverDone) => { + if (!serverDone) return; + setHasCompleted(true); + try { await AsyncStorage.setItem(DONE_KEY, 'true'); } catch (_) {} }, []); const clearJustCompleted = useCallback(() => setJustCompleted(false), []); @@ -180,7 +196,7 @@ export function TutorialProvider({ children }) { registerTarget, clearTargets, registerScrollRef, registerRemeasure, scrollToStep, startChapter, nextStep, prevStep, completeActionStep, - dismiss, resetTutorial, + dismiss, resetTutorial, reconcileWithServer, }}> {children} diff --git a/front/src/data/__tests__/tutorialChapters.test.js b/front/src/data/__tests__/tutorialChapters.test.js new file mode 100644 index 0000000..ad8a283 --- /dev/null +++ b/front/src/data/__tests__/tutorialChapters.test.js @@ -0,0 +1,167 @@ +// @ts-check +// Tests de non-régression des données du tutoriel interactif (tutorialChapters.js). +// Ils tournent dans Node.js via Jest + babel-jest (transform ES → CJS) — aucune +// dépendance React Native n'est requise. +// +// Objectif : garantir l'intégrité structurelle qui fait tourner la machine à états +// du TutorialContext (navigation de chapitre en chapitre, spotlights, bouton +// "Terminer" affiché au bon moment). Une régression ici casserait silencieusement +// le tour guidé sans erreur JS. + +const { + TUTORIAL_CHAPTERS, + CHAPTER_MAP, + CHAPTER_IDS, +} = require('../tutorialChapters'); + +const VALID_POSITIONS = ['top', 'bottom', 'center']; + +// ───────────────────────────────────────────────────────────────────────────── +// Structure des chapitres +// ───────────────────────────────────────────────────────────────────────────── +describe('TUTORIAL_CHAPTERS — structure des chapitres', () => { + it('exporte un tableau non vide de chapitres', () => { + expect(Array.isArray(TUTORIAL_CHAPTERS)).toBe(true); + expect(TUTORIAL_CHAPTERS.length).toBeGreaterThan(0); + }); + + it('contient exactement les 8 chapitres attendus, dans l\'ordre', () => { + expect(CHAPTER_IDS).toEqual([ + 'dashboard', + 'workout', + 'profile', + 'trophies', + 'inventory', + 'social', + 'stats', + 'settings', + ]); + }); + + const REQUIRED_CHAPTER_KEYS = ['id', 'title', 'subtitle', 'icon', 'tabName', 'steps']; + + test.each(TUTORIAL_CHAPTERS)( + 'chapitre "$id" — possède toutes les propriétés obligatoires', + (chapter) => { + REQUIRED_CHAPTER_KEYS.forEach((key) => { + expect(chapter).toHaveProperty(key); + expect(chapter[key]).not.toBeUndefined(); + expect(chapter[key]).not.toBeNull(); + }); + }, + ); + + it('tous les IDs de chapitre sont uniques', () => { + const ids = TUTORIAL_CHAPTERS.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('chaque chapitre a au moins une étape', () => { + TUTORIAL_CHAPTERS.forEach((chapter) => { + expect(Array.isArray(chapter.steps)).toBe(true); + expect(chapter.steps.length).toBeGreaterThan(0); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Structure des étapes +// ───────────────────────────────────────────────────────────────────────────── +describe('TUTORIAL_CHAPTERS — structure des étapes', () => { + const allSteps = TUTORIAL_CHAPTERS.flatMap((c) => + c.steps.map((s) => ({ chapterId: c.id, step: s })), + ); + + test.each(allSteps)( + '$chapterId/$step.key — champs texte et position valides', + ({ step }) => { + expect(typeof step.key).toBe('string'); + expect(step.key.length).toBeGreaterThan(0); + expect(typeof step.title).toBe('string'); + expect(step.title.length).toBeGreaterThan(0); + expect(typeof step.body).toBe('string'); + expect(step.body.length).toBeGreaterThan(0); + expect(VALID_POSITIONS).toContain(step.position); + // targetKey est soit null (carte centrée), soit une chaîne non vide. + if (step.targetKey !== null) { + expect(typeof step.targetKey).toBe('string'); + expect(step.targetKey.length).toBeGreaterThan(0); + } + }, + ); + + it('les clés d\'étape sont uniques au sein de chaque chapitre', () => { + TUTORIAL_CHAPTERS.forEach((chapter) => { + const keys = chapter.steps.map((s) => s.key); + expect(new Set(keys).size).toBe(keys.length); + }); + }); + + it('toute étape avec un spotlight (targetKey) l\'associe à une position ancrée', () => { + // Un spotlight n'a de sens qu'ancré en haut/bas de la cible (pas 'center'). + allSteps + .filter(({ step }) => step.targetKey !== null) + .forEach(({ chapterId, step }) => { + expect(['top', 'bottom']).toContain(step.position); + // message d'aide au debug si ça casse + if (!['top', 'bottom'].includes(step.position)) { + throw new Error(`${chapterId}/${step.key} a un targetKey mais position=${step.position}`); + } + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Cohérence du flag isLast (pilote l'affichage du bouton "Terminer") +// ───────────────────────────────────────────────────────────────────────────── +describe('TUTORIAL_CHAPTERS — flag isLast', () => { + it('exactement une étape porte isLast: true dans tout le tutoriel', () => { + const flagged = TUTORIAL_CHAPTERS.flatMap((c) => + c.steps.filter((s) => s.isLast === true), + ); + expect(flagged).toHaveLength(1); + }); + + it('isLast est porté par la toute dernière étape du dernier chapitre', () => { + const lastChapter = TUTORIAL_CHAPTERS[TUTORIAL_CHAPTERS.length - 1]; + const lastStep = lastChapter.steps[lastChapter.steps.length - 1]; + expect(lastStep.isLast).toBe(true); + }); + + it('aucune autre étape que la dernière ne porte isLast', () => { + TUTORIAL_CHAPTERS.forEach((chapter, ci) => { + chapter.steps.forEach((step, si) => { + const isVeryLast = + ci === TUTORIAL_CHAPTERS.length - 1 && si === chapter.steps.length - 1; + if (!isVeryLast) { + expect(step.isLast).not.toBe(true); + } + }); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Maps dérivées (utilisées par TutorialContext pour la navigation d'état) +// ───────────────────────────────────────────────────────────────────────────── +describe('CHAPTER_MAP & CHAPTER_IDS — cohérence dérivée', () => { + it('CHAPTER_IDS correspond à l\'ordre des chapitres', () => { + expect(CHAPTER_IDS).toEqual(TUTORIAL_CHAPTERS.map((c) => c.id)); + }); + + it('CHAPTER_MAP indexe chaque chapitre par son id', () => { + TUTORIAL_CHAPTERS.forEach((chapter) => { + expect(CHAPTER_MAP[chapter.id]).toBe(chapter); + }); + expect(Object.keys(CHAPTER_MAP)).toHaveLength(TUTORIAL_CHAPTERS.length); + }); + + it('chaque chapitre avec stackScreen déclare aussi son tabName', () => { + // La transition inter-chapitres navigue via navigation.navigate(tabName, + // { screen: stackScreen }) — stackScreen sans tabName casserait la navigation. + TUTORIAL_CHAPTERS.filter((c) => c.stackScreen).forEach((c) => { + expect(typeof c.tabName).toBe('string'); + expect(c.tabName.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/front/src/data/tutorialChapters.js b/front/src/data/tutorialChapters.js index b9551e8..66255f8 100644 --- a/front/src/data/tutorialChapters.js +++ b/front/src/data/tutorialChapters.js @@ -1,4 +1,4 @@ -// Définition des 6 chapitres du tutoriel interactif Athly. +// Définition des 8 chapitres du tutoriel interactif Athly. // // Propriétés d'une step : // targetKey – clé d'une cible enregistrée via useTutorialTarget (null = pas de spotlight) @@ -23,7 +23,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'welcome', title: 'Bienvenue sur Athly !', - body: "Athly transforme chaque séance en progression réelle. Ce guide de 6 chapitres te montrera tout ce que tu peux accomplir.", + body: "Athly transforme chaque séance en progression réelle. Ce guide de 8 chapitres te montrera tout ce que tu peux accomplir.", targetKey: null, position: 'center', scrollY: 0, }, { @@ -139,6 +139,12 @@ export const TUTORIAL_CHAPTERS = [ body: "Ces 3 slots affichent tes trophées préférés. Appuie sur un slot vide pour choisir dans la Salle des Trophées.", targetKey: 'profile_vitrine', position: 'top', scrollY: 260, }, + { + key: 'profile_titles', + title: 'Tes Titres RPG', + body: "Via le bouton 'Titres', équipe un titre affiché sous ton pseudo. 17 titres se débloquent en accomplissant des défis (records, séances Multi, entraide…).", + targetKey: null, position: 'center', scrollY: 0, + }, ], }, @@ -154,7 +160,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'trophies_intro', title: 'La Salle des Trophées', - body: "40 trophées à débloquer, répartis en 8 catégories : Héritage, Force, Exploration, Secret, Corps, Régularité, Social et Spécial.", + body: "60 trophées à débloquer, répartis en 9 catégories : Héritage, Force, Exploration, Secret, Corps, Régularité, Social, Spécial et Collection.", targetKey: null, position: 'center', scrollY: 0, }, { @@ -166,7 +172,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'trophies_ultimate', title: 'Le Trophée Ultime', - body: "Le 'Souverain Absolu' se débloque automatiquement quand les 40 autres trophées sont obtenus. Un accomplissement absolu.", + body: "Le 'Souverain Absolu' se débloque automatiquement quand les 60 autres trophées sont obtenus. Un accomplissement absolu.", targetKey: 'trophies_ultimate', position: 'bottom', scrollY: 0, }, { @@ -178,11 +184,95 @@ export const TUTORIAL_CHAPTERS = [ ], }, - // ─── CHAPITRE 5 : Les Statistiques ───────────────────────────────────────── + // ─── CHAPITRE 5 : Inventaire, Coffres & Raretés ──────────────────────────── + { + id: 'inventory', + title: 'Coffres & Raretés', + subtitle: 'Chapitre 5', + icon: 'cube-outline', + tabName: 'ProfileTab', + stackScreen: 'Inventory', + steps: [ + { + key: 'inventory_intro', + title: "L'Inventaire", + body: "Chaque effort te récompense : boissons d'XP, gels de streak, boosts et coupons s'accumulent ici, prêts à être utilisés au bon moment.", + targetKey: null, position: 'center', scrollY: 0, + }, + { + key: 'inventory_chest', + title: 'Coffres à l\'Effort', + body: "Tu gagnes un coffre toutes les 2 h de séance cumulées (débloqué au rang Initié, niveau 11). Ouvre-le pour tirer un objet au hasard.", + targetKey: 'inventory_chest', position: 'bottom', scrollY: 0, + }, + { + key: 'inventory_rarities', + title: '5 Raretés', + body: "Du Commun (gris) au Légendaire (orange), en passant par Rare (bleu) et Épique (violet) : plus c'est rare, plus l'objet est puissant.", + targetKey: null, position: 'center', scrollY: null, + }, + { + key: 'inventory_unique', + title: 'Le Rouge Sang Unique', + body: "La rareté ultime : les cosmétiques Uniques Rouge Sang (cadre, couleur, thème) se méritent par des exploits rares et se réclament ici. Le prestige absolu.", + targetKey: null, position: 'center', scrollY: null, + }, + ], + }, + + // ─── CHAPITRE 6 : Social, Groupes & Multi ────────────────────────────────── + { + id: 'social', + title: 'Amis, Groupes & Multi', + subtitle: 'Chapitre 6', + icon: 'people-outline', + tabName: 'SocialTab', + stackScreen: 'SocialHub', + steps: [ + { + key: 'social_intro', + title: 'Progresse en Meute', + body: "Athly est bien plus fort à plusieurs. Ajoute tes amis, compare-toi, et entraîne-toi en équipe.", + targetKey: null, position: 'center', scrollY: 0, + }, + { + key: 'social_segments', + title: 'Amis, Classement, Groupe', + body: "Ces trois onglets réunissent ton réseau : tes amis, les classements (XP et records par exercice), et ton Groupe de Streak.", + targetKey: 'social_segments', position: 'bottom', scrollY: 0, + }, + { + key: 'social_add', + title: 'Ajouter un Ami', + body: "Chaque joueur a un tag unique façon Discord (ex: Player#1234). Saisis-le pour envoyer une demande, après un aperçu de son profil.", + targetKey: null, position: 'center', scrollY: null, + }, + { + key: 'social_group', + title: 'Groupes de Streak & Météo', + body: "Formez un groupe (5 max) : si TOUS validez votre journée, la streak collective grimpe. La Météo des séances montre en direct qui est Prêt, Actif ou a Validé.", + targetKey: null, position: 'center', scrollY: null, + }, + { + key: 'social_shame', + title: 'Hall of Shame & Secouer', + body: "Si la streak de groupe casse, le dernier Briseur s'affiche dans le Hall of Shame. Le bouton 'Secouer' envoie une notif troll aux retardataires pour les motiver.", + targetKey: null, position: 'center', scrollY: null, + }, + { + key: 'social_multi', + title: 'Le Mode Multi', + body: "Invite des amis dans un Lobby Multi : vous démarrez la séance ensemble, chacun sur son écran. Terminer à plusieurs débloque un bonus d'XP de groupe (+15% à 2, jusqu'à +50% à 5).", + targetKey: null, position: 'center', scrollY: null, + }, + ], + }, + + // ─── CHAPITRE 7 : Les Statistiques ───────────────────────────────────────── { id: 'stats', title: 'Les Statistiques', - subtitle: 'Chapitre 5', + subtitle: 'Chapitre 7', icon: 'stats-chart-outline', tabName: 'Stats', // useMockData = true → StatsScreen injecte les fausses données durant ce chapitre @@ -200,6 +290,12 @@ export const TUTORIAL_CHAPTERS = [ body: "Séances, sets validés et volume total pour la période sélectionnée. Change la période avec les boutons Semaine / Mois / Tout.", targetKey: 'stats_kpis', position: 'bottom', scrollY: 60, }, + { + key: 'stats_weight', + title: 'Suivi de Poids', + body: "Le graphique double courbe suit ton poids réel vers ton objectif. Appuie sur 'Ajouter une pesée' pour l'actualiser et garder un suivi au top.", + targetKey: 'stats_weight_chart', position: 'bottom', scrollY: 60, + }, { key: 'stats_volume', title: 'Graphique de Volume', @@ -222,11 +318,11 @@ export const TUTORIAL_CHAPTERS = [ ], }, - // ─── CHAPITRE 6 : Réglages ────────────────────────────────────────────────── + // ─── CHAPITRE 8 : Réglages ────────────────────────────────────────────────── { id: 'settings', title: 'Réglages & Compte', - subtitle: 'Chapitre 6', + subtitle: 'Chapitre 8', icon: 'settings-outline', tabName: 'ProfileTab', stackScreen: 'Settings', diff --git a/front/src/screens/Home/HomeScreen.js b/front/src/screens/Home/HomeScreen.js index c8aba2c..a5117ff 100644 --- a/front/src/screens/Home/HomeScreen.js +++ b/front/src/screens/Home/HomeScreen.js @@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Colors, MUSCLE_GROUP_COLORS } from '../../constants/theme'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; +import { useUser } from '../../context/UserContext'; import { computeStreak, recommendNextMuscleGroup, @@ -49,9 +50,16 @@ export default function HomeScreen({ navigation }) { const { hasCompleted, bootstrapped, pendingChapterId, startChapter, activeChapterId, activeStep, stepIndex, - registerScrollRef, registerRemeasure, + registerScrollRef, registerRemeasure, reconcileWithServer, } = useTutorial(); + // Réconciliation du flag "tutoriel terminé" avec le backend (cohérence + // inter-appareils) : si le serveur dit "déjà fait", on ne re-déclenche pas. + const { user } = useUser(); + useEffect(() => { + if (user) reconcileWithServer(!!user.hasCompletedOnboarding); + }, [user, reconcileWithServer]); + // Injection de données fantômes pendant le Chapitre 1 pour que le spotlight // puisse pointer les éléments actifs (level chip, hero, stats, quêtes, rituel). const isTutorialDashboard = activeChapterId === 'dashboard'; @@ -98,6 +106,9 @@ export default function HomeScreen({ navigation }) { useFocusEffect( useCallback(() => { if (!bootstrapped) return; + // Garde inter-appareils : ne pas auto-lancer si le backend confirme que + // le tutoriel a déjà été fait (même si le flag local n'est pas encore là). + if (user && user.hasCompletedOnboarding) return; if (!hasCompleted && activeChapterId === null) { const timer = setTimeout(() => startChapter('dashboard'), 600); return () => clearTimeout(timer); @@ -106,7 +117,7 @@ export default function HomeScreen({ navigation }) { const timer = setTimeout(() => startChapter('dashboard'), 400); return () => clearTimeout(timer); } - }, [bootstrapped, hasCompleted, pendingChapterId, activeChapterId, startChapter]), + }, [bootstrapped, hasCompleted, pendingChapterId, activeChapterId, startChapter, user]), ); // ─── Anim d'entrée pour éviter le flash content au 1er chargement ──── diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index 6001f53..43cc559 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -15,6 +15,8 @@ import { useAvatarFrame } from '../../hooks/useAvatarFrame'; import { useDevSettings } from '../../hooks/useDevSettings'; import ChestOpeningModal from '../../components/inventory/ChestOpeningModal'; import { haptics } from '../../services/haptics.service'; +import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; +import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; const MIN_LEVEL_FOR_CHEST = 11; const RARITY_ORDER = ['unique', 'legendary', 'epic', 'rare', 'common']; @@ -42,8 +44,21 @@ export default function InventoryScreen({ navigation }) { const [busy, setBusy] = useState(false); const [chestModal, setChestModal] = useState({ visible: false, drawnItem: null }); + // ─── Tutorial (chapitre Inventaire) ────────────────────────────────────────── + const { pendingChapterId, activeChapterId, startChapter } = useTutorial(); + const { ref: chestRef, onLayout: onChestLayout } = useTutorialTarget('inventory_chest'); + useFocusEffect(useCallback(() => { refetch(); }, [refetch])); + useFocusEffect( + useCallback(() => { + if (pendingChapterId === 'inventory') { + const t = setTimeout(() => startChapter('inventory'), 400); + return () => clearTimeout(t); + } + }, [pendingChapterId, startChapter]), + ); + const inventory = user?.inventory ?? []; const chestEntry = inventory.find((i) => i.itemType === 'CHEST_KEY'); const chestCount = chestEntry?.quantity ?? 0; @@ -150,12 +165,16 @@ export default function InventoryScreen({ navigation }) { {/* ── Coffres ── */} - + {/* marginTop porté par le wrapper (et non la carte) pour que le + spotlight du tutoriel épouse pile la carte, sans décalage vertical. */} + + + {/* ── Objets ── */} MES OBJETS @@ -187,6 +206,10 @@ export default function InventoryScreen({ navigation }) { drawnItem={chestModal.drawnItem} onClose={closeChestModal} /> + + {activeChapterId === 'inventory' && ( + + )} ); } @@ -338,6 +361,7 @@ const styles = StyleSheet.create({ }, // ── Coffre ── + chestSpot: { marginTop: 8 }, chestCard: { flexDirection: 'row', alignItems: 'center', @@ -346,7 +370,6 @@ const styles = StyleSheet.create({ borderColor: 'rgba(254,116,57,0.30)', borderRadius: 18, padding: 18, - marginTop: 8, }, chestCardLocked: { backgroundColor: 'rgba(255,255,255,0.04)', diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 4ea224f..4511499 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -1173,7 +1173,7 @@ export default function SettingsScreen({ navigation }) { Rejouer l'intégralité du tutoriel - 5 chapitres · du Dashboard aux Réglages + 8 chapitres · du Dashboard aux Réglages @@ -1192,7 +1192,7 @@ export default function SettingsScreen({ navigation }) { {chapter.title} - {chapter.subtitle} · {chapter.steps.length} étapes + Chapitre {idx + 1} · {chapter.steps.length} étapes diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index 2a05fef..23289c3 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -21,6 +21,8 @@ import { } from '../../services/social.service'; import { MAJOR_EXERCISES } from '../../data/majorExercises'; import ExercisePickerModal from '../../components/social/ExercisePickerModal'; +import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; +import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; // Podium / classements : positions 1-3 affichées en médaille colorée plutôt // qu'en emoji 🥇🥈🥉. @@ -48,6 +50,10 @@ export default function SocialScreen({ navigation }) { const { addBonusXp } = useWorkoutLogs(); const [segment, setSegment] = useState('friends'); + // ─── Tutorial (chapitre Social) ────────────────────────────────────────────── + const { pendingChapterId, activeChapterId, startChapter } = useTutorial(); + const { ref: segmentsRef, onLayout: onSegmentsLayout } = useTutorialTarget('social_segments'); + // ── Données ── const [friends, setFriends] = useState([]); const [pending, setPending] = useState([]); @@ -130,6 +136,15 @@ export default function SocialScreen({ navigation }) { useFocusEffect(useCallback(() => { loadAll(); }, [loadAll])); + useFocusEffect( + useCallback(() => { + if (pendingChapterId === 'social') { + const t = setTimeout(() => startChapter('social'), 400); + return () => clearTimeout(t); + } + }, [pendingChapterId, startChapter]), + ); + const onRefresh = () => { setRefreshing(true); loadAll(); }; // Recherche déclenchée depuis AddFriendModal, avec le tag déjà construit à @@ -173,7 +188,12 @@ export default function SocialScreen({ navigation }) { Social {/* ── Segments ── */} - + {SEGMENTS.map((s) => { const active = segment === s.key; const badge = s.key === 'friends' && pending.length > 0 ? pending.length @@ -335,6 +355,10 @@ export default function SocialScreen({ navigation }) { }} onClose={() => setPreviewResult(null)} /> + + {activeChapterId === 'social' && ( + + )} ); } diff --git a/front/src/screens/Stats/StatsScreen.js b/front/src/screens/Stats/StatsScreen.js index 7ef02db..6c49d54 100644 --- a/front/src/screens/Stats/StatsScreen.js +++ b/front/src/screens/Stats/StatsScreen.js @@ -67,7 +67,12 @@ export default function StatsScreen({ navigation }) { } = useTutorial(); const scrollRef = useRef(null); + // Offset de scroll courant, suivi en direct pour un défilement piloté par la + // position RÉELLE des cibles (robuste aux changements de mise en page comme + // l'ajout du graphique de poids, qui décalait les anciens scrollY fixes). + const scrollOffsetRef = useRef(0); const { ref: kpisRef, onLayout: onKpisLayout, remeasure: rKpis } = useTutorialTarget('stats_kpis'); + const { ref: weightRef, onLayout: onWeightLayout, remeasure: rWeight } = useTutorialTarget('stats_weight_chart'); const { ref: volumeRef, onLayout: onVolumeLayout, remeasure: rVolume } = useTutorialTarget('stats_volume_chart'); const { ref: muscleRef, onLayout: onMuscleLayout, remeasure: rMuscle } = useTutorialTarget('stats_muscle_chart'); const { ref: tabHistoryRef, onLayout: onTabHistoryLayout } = useTutorialTarget('stats_tab_history'); @@ -76,9 +81,24 @@ export default function StatsScreen({ navigation }) { useEffect(() => { registerScrollRef('stats', scrollRef); registerRemeasure('stats', () => { - setTimeout(() => { rKpis(); rVolume(); rMuscle(); }, 50); + setTimeout(() => { rKpis(); rWeight(); rVolume(); rMuscle(); }, 50); }); - }, [registerScrollRef, registerRemeasure, rKpis, rVolume, rMuscle]); + }, [registerScrollRef, registerRemeasure, rKpis, rWeight, rVolume, rMuscle]); + + // Fait défiler pour amener une cible mesurée à une position confortable : + // haut de cible vers ~160 px (tooltip en-dessous) ou ~300 px (tooltip + // au-dessus). On mesure en absolu (pageY) et on combine avec l'offset courant, + // donc aucun nombre magique ne dépend de la hauteur des sections au-dessus. + const scrollTargetIntoView = useCallback((targetRef, prefersAbove, remeasureAll) => { + if (!targetRef?.current || !scrollRef.current) return; + const desiredTop = prefersAbove ? 300 : 160; + targetRef.current.measure((_x, _y, _w, _h, _pageX, pageY) => { + if (pageY == null) return; + const newY = Math.max(0, scrollOffsetRef.current + (pageY - desiredTop)); + scrollRef.current.scrollTo({ y: newY, animated: true }); + if (remeasureAll) setTimeout(remeasureAll, 350); + }); + }, []); // Démarrage du chapitre quand l'écran gagne le focus. // On force d'abord l'onglet Performance pour éviter que l'utilisateur, @@ -93,13 +113,33 @@ export default function StatsScreen({ navigation }) { }, [pendingChapterId, startChapter]), ); - // Auto-scroll + autoActions quand l'étape change + // Auto-scroll + autoActions quand l'étape change. + // Défilement piloté par la cible (robuste) pour les sections défilables ; + // pour la cible d'onglet Historique (barre de nav haute) on remonte en tête. useEffect(() => { if (activeChapterId !== 'stats' || !activeStep) return; - const y = activeStep.scrollY; - if (y != null && scrollRef.current) { - scrollRef.current.scrollTo({ y, animated: true }); - setTimeout(() => { rKpis(); rVolume(); rMuscle(); }, 350); + + const remeasureAll = () => { rKpis(); rWeight(); rVolume(); rMuscle(); }; + const REF_BY_KEY = { + stats_kpis: kpisRef, + stats_weight_chart: weightRef, + stats_volume_chart: volumeRef, + stats_muscle_chart: muscleRef, + }; + const targetRef = activeStep.targetKey ? REF_BY_KEY[activeStep.targetKey] : null; + + if (targetRef) { + // Laisse le rendu se stabiliser puis amène la cible à bonne hauteur. + const t = setTimeout( + () => scrollTargetIntoView(targetRef, activeStep.position === 'top', remeasureAll), + 80, + ); + return () => clearTimeout(t); + } + // Cibles hors flux défilable (onglet) ou cartes centrées : scroll fixe. + if (activeStep.scrollY != null && scrollRef.current) { + scrollRef.current.scrollTo({ y: activeStep.scrollY, animated: true }); + setTimeout(remeasureAll, 350); } if (activeStep.autoAction === 'switchToHistory') { const t = setTimeout(() => setTab('history'), 300); @@ -159,6 +199,8 @@ export default function StatsScreen({ navigation }) { style={styles.scroll} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} + scrollEventThrottle={16} + onScroll={(e) => { scrollOffsetRef.current = e.nativeEvent.contentOffset.y; }} > Statistiques @@ -194,17 +236,19 @@ export default function StatsScreen({ navigation }) { - - - setWeightEntryVisible(true)} - activeOpacity={0.85} - > - - Ajouter une pesée - - + + + + setWeightEntryVisible(true)} + activeOpacity={0.85} + > + + Ajouter une pesée + + + diff --git a/front/src/services/onboarding.service.js b/front/src/services/onboarding.service.js new file mode 100644 index 0000000..4059778 --- /dev/null +++ b/front/src/services/onboarding.service.js @@ -0,0 +1,10 @@ +import API from '../api/api'; + +// ─── Onboarding / Tutoriel interactif ────────────────────────────────────────── +// Marque le tutoriel terminé côté backend — best-effort : la vérité immédiate +// reste AsyncStorage (voir TutorialContext.js), ce flag serveur ne sert que la +// cohérence inter-appareils. Ne jamais bloquer l'UI sur cet appel. +export async function completeOnboarding() { + const res = await API.post('/users/me/complete-onboarding'); + return res.data; +} From 8c5f2a7706a6bf72b785328904fa6861070be9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Fri, 10 Jul 2026 16:57:19 +0200 Subject: [PATCH 24/31] fix(sync): restauration de la logique de synchronisation XP perdue lors du merge --- back/controllers/user.controller.js | 18 +++++ back/routes/user.routes.js | 4 ++ back/services/user.service.js | 54 ++++++++++++++ back/tests/user.test.js | 105 ++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+) diff --git a/back/controllers/user.controller.js b/back/controllers/user.controller.js index c8a7a8a..56de9b7 100644 --- a/back/controllers/user.controller.js +++ b/back/controllers/user.controller.js @@ -142,4 +142,22 @@ exports.completeOnboarding = async (req, res, next) => { } catch (error) { next(error); } +}; + +/** + * SYNCHRONISER L'XP LOCALE + * Pousse l'XP totale accumulée localement (front, AsyncStorage-first) vers + * user.xp/level backend — Source de Vérité pour tout ce qui est gated côté + * serveur (coffres niveau 11+, conditions de titres). Voir user.service.js + * → syncXp pour le détail du ratchet anti-régression et du recalcul de niveau. + */ +exports.syncXp = async (req, res, next) => { + try { + const { xp } = req.body; + const result = await userService.syncXp(req.user.id, xp); + + res.status(200).json({ success: true, ...result }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/back/routes/user.routes.js b/back/routes/user.routes.js index 073981b..582df12 100644 --- a/back/routes/user.routes.js +++ b/back/routes/user.routes.js @@ -31,6 +31,10 @@ router.put("/me/push-token", auth, validate(registerPushToken), userController.r // Marquer le tutoriel/onboarding comme terminé — pas de body (idempotent) router.post("/me/complete-onboarding", auth, userController.completeOnboarding); +// Synchronise l'XP totale locale (front) vers le backend — Source de Vérité +// pour le gating serveur (coffres niveau 11+, conditions de titres). +router.post("/me/sync-xp", auth, validate(syncXp), userController.syncXp); + // Suppression définitive du compte (RGPD) router.delete("/delete-account", auth, userController.deleteAccount); diff --git a/back/services/user.service.js b/back/services/user.service.js index a947dcc..48f09cf 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -172,6 +172,60 @@ class UserService { return updatedUser; } + /** + * Synchronise l'XP totale calculée localement (front, AsyncStorage-first) + * vers user.xp/level backend — Source de Vérité pour tout ce qui est gated + * côté serveur (coffres niveau 11+, conditions de titres comme PERFORM_LEVEL_50). + * + * Sans ce point de synchro explicite, le niveau backend dérive silencieusement + * de celui vécu par le joueur : `finalizeWorkout` calcule sa PROPRE xp + * (anti-cheat serveur, à partir des exercices soumis) plutôt que d'adopter + * l'xp locale — les deux ledgers divergent avec le temps. + * + * Sécurité : XP en lecture seule ratchet (ne redescend jamais) — un sync + * redondant ou tardif ne peut jamais effacer une progression déjà actée. + * Bornée à xpForLevel(MAX_LEVEL) pour rejeter tout payload absurde (la + * validation Joi en amont ne fait qu'un garde-fou grossier). + * + * @param {string} userId + * @param {number} submittedXp — XP cumulée locale (jamais un delta) + * @returns {{ level, xp, rank, newlyUnlockedTitles }} + */ + async syncXp(userId, submittedXp) { + const { levelFromXP, getRankForLevel, xpForLevel, MAX_LEVEL } = require('../utils/levelHelpers'); + + const user = await User.findById(userId).select('xp level rank'); + if (!user) throw new Error('Utilisateur non trouvé.'); + + const clampedXp = Math.max(0, Math.min(submittedXp, xpForLevel(MAX_LEVEL))); + const nextXp = Math.max(user.xp || 0, clampedXp); + + let newlyUnlockedTitles = []; + + if (nextXp !== user.xp) { + user.xp = nextXp; + user.level = levelFromXP(nextXp); + user.rank = getRankForLevel(user.level); + await user.save(); + + // Require tardif : évite le cycle user.service ↔ title.controller au + // chargement des modules (voir même idiome dans reward.controller.js). + try { + const { checkAndUnlockTitles } = require('../controllers/title.controller'); + newlyUnlockedTitles = await checkAndUnlockTitles(userId); + } catch (_) { + // best-effort — la synchro XP elle-même ne doit jamais échouer pour ça. + } + } + + return { + level: user.level, + xp: user.xp, + rank: user.rank, + newlyUnlockedTitles, + }; + } + async addExperience(userId, xpAmount) { const user = await User.findById(userId); user.xp += xpAmount; diff --git a/back/tests/user.test.js b/back/tests/user.test.js index 209ce01..fa24284 100644 --- a/back/tests/user.test.js +++ b/back/tests/user.test.js @@ -129,4 +129,109 @@ describe('User API (Routes Protégées)', () => { expect(res.statusCode).toBe(401); }); }); + + describe('POST /api/users/me/sync-xp — syncXp (Section X)', () => { + const { xpForLevel } = require('../utils/levelHelpers'); + + beforeEach(async () => { + await User.updateOne( + { email: 'user@test.fr' }, + { $set: { xp: 0, level: 0, rank: 'Novice', unlockedTitles: [] } }, + ); + }); + + it('✅ Recalcule level/rank côté serveur à partir de xp', async () => { + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(12) }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(12); + expect(res.body.rank).toBe('Initié'); + expect(res.body.xp).toBe(xpForLevel(12)); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.level).toBe(12); + expect(user.xp).toBe(xpForLevel(12)); + }); + + it("✅ Ratchet : un xp inférieur à l'existant ne fait jamais redescendre le niveau", async () => { + await User.updateOne({ email: 'user@test.fr' }, { $set: { xp: xpForLevel(20), level: 20, rank: 'Initié' } }); + + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(5) }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(20); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.level).toBe(20); + }); + + it('✅ Idempotent : renvoyer la même valeur ne déclenche pas de re-déblocage', async () => { + await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + const second = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + expect(second.body.newlyUnlockedTitles).not.toContain('PERFORM_LEVEL_10'); + }); + + it('✅ Débloque les titres dont la condition de niveau est franchie', async () => { + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: xpForLevel(10) }); + + expect(res.body.newlyUnlockedTitles).toContain('PERFORM_LEVEL_10'); + + const user = await User.findOne({ email: 'user@test.fr' }); + expect(user.unlockedTitles).toContain('PERFORM_LEVEL_10'); + }); + + it('✅ Clampe un xp aberrant (au-delà du niveau 200) sans planter', async () => { + // En dessous du plafond Joi (garde-fou grossier, 100M) mais bien + // au-delà de xpForLevel(200) (~1.72M) — exerce le clamp du service. + const res = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: 50000000 }); + + expect(res.statusCode).toBe(200); + expect(res.body.level).toBe(200); + }); + + it('❌ 400 si xp négatif, non numérique ou manquant', async () => { + const negative = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: -5 }); + expect(negative.statusCode).toBe(400); + + const invalid = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({ xp: 'abc' }); + expect(invalid.statusCode).toBe(400); + + const missing = await request(app) + .post('/api/users/me/sync-xp') + .set('Authorization', `Bearer ${token}`) + .send({}); + expect(missing.statusCode).toBe(400); + }); + + it('❌ 401 sans token', async () => { + const res = await request(app).post('/api/users/me/sync-xp').send({ xp: 100 }); + expect(res.statusCode).toBe(401); + }); + }); }); \ No newline at end of file From 89020349bf605e23c35c04dbb611f7418b405a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Mon, 13 Jul 2026 11:01:16 +0200 Subject: [PATCH 25/31] feat(tutorial): ajustements et finitions sur les chapitres du onboarding --- .../components/tutorial/TutorialOverlay.js | 19 ++++++++------ front/src/context/TutorialContext.js | 11 +++++++- front/src/screens/Profile/InventoryScreen.js | 25 ++++++++++++++----- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/front/src/components/tutorial/TutorialOverlay.js b/front/src/components/tutorial/TutorialOverlay.js index 91a7f0d..b34cf25 100644 --- a/front/src/components/tutorial/TutorialOverlay.js +++ b/front/src/components/tutorial/TutorialOverlay.js @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useCallback, useState } from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity, - Animated, useWindowDimensions, Platform, + Animated, useWindowDimensions, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTutorial } from '../../context/TutorialContext'; @@ -87,11 +87,16 @@ export default function TutorialOverlay({ navigation }) { isLastStep, targets, nextStep, dismiss, } = useTutorial(); - const { width: W, height: H } = useWindowDimensions(); - // Sur web desktop, le portail Modal est recadré à 430 px centré (CSS dans index.js). - // Les coordonnées renvoyées par measure() sont en coords viewport, donc on soustrait - // l'offset gauche du conteneur pour obtenir des coordonnées locales au portail. - const modalLeft = Platform.OS === 'web' && W > 430 ? (W - 430) / 2 : 0; + const { height: H } = useWindowDimensions(); + // Sur web, le de react-native-web portale dans un
en + // position:fixed attaché à document.body — INDÉPENDANT du conteneur #root + // (recentré à 430 px sur desktop via web/index.html). measure() renvoie déjà + // des coordonnées relatives au viewport complet, le même repère que celui + // utilisé par ce Modal plein-viewport : aucune correction d'offset n'est à + // appliquer. Un ancien code soustrayait un offset "modalLeft" en supposant + // le Modal lui-même recentré à 430 px — ce qui décalait le spotlight de + // plusieurs centaines de pixels sur une fenêtre desktop large (le cadre se + // retrouvait loin de la carte réellement visible, recentrée dans #root). const slideY = useRef(new Animated.Value(24)).current; const opacity = useRef(new Animated.Value(0)).current; @@ -120,7 +125,7 @@ export default function TutorialOverlay({ navigation }) { const hasSpot = !!targetRect; const sp = hasSpot ? { - x: (targetRect.x - modalLeft) - SPOTLIGHT_PADDING, + x: targetRect.x - SPOTLIGHT_PADDING, y: targetRect.y - SPOTLIGHT_PADDING, w: targetRect.width + SPOTLIGHT_PADDING * 2, h: targetRect.height + SPOTLIGHT_PADDING * 2, diff --git a/front/src/context/TutorialContext.js b/front/src/context/TutorialContext.js index a6384b4..87bd0b8 100644 --- a/front/src/context/TutorialContext.js +++ b/front/src/context/TutorialContext.js @@ -225,8 +225,17 @@ export function useTutorialTarget(key) { }); }, [key, registerTarget]); + // Plusieurs mesures échelonnées sur ~1s : sur un écran atteint via une + // transition de stack (slide horizontal, @react-navigation/stack), la + // position mesurée juste après le premier layout peut encore refléter un + // état transitoire (mi-glissement, décalé vers la droite) plutôt que la + // position de repos finale — measure() renvoie la position réellement + // rendue à l'écran, transform en cours inclus. Chaque mesure écrase la + // précédente (registerTarget), donc la dernière — une fois la transition + // calmée — corrige automatiquement le spotlight, quel que soit le + // mécanisme ou la durée exacte de la transition. const onLayout = useCallback(() => { - setTimeout(measure, 80); + [80, 250, 450, 700, 1000].forEach((delay) => setTimeout(measure, delay)); }, [measure]); return { ref, onLayout, remeasure: measure }; diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index 43cc559..6796bdc 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -46,17 +46,30 @@ export default function InventoryScreen({ navigation }) { // ─── Tutorial (chapitre Inventaire) ────────────────────────────────────────── const { pendingChapterId, activeChapterId, startChapter } = useTutorial(); - const { ref: chestRef, onLayout: onChestLayout } = useTutorialTarget('inventory_chest'); + const { ref: chestRef, onLayout: onChestLayout, remeasure: rChest } = useTutorialTarget('inventory_chest'); useFocusEffect(useCallback(() => { refetch(); }, [refetch])); useFocusEffect( useCallback(() => { - if (pendingChapterId === 'inventory') { - const t = setTimeout(() => startChapter('inventory'), 400); - return () => clearTimeout(t); - } - }, [pendingChapterId, startChapter]), + if (pendingChapterId !== 'inventory') return; + // ProfileStack (@react-navigation/stack) glisse cet écran depuis la + // droite : mesurer la cible pendant la transition capture sa position + // mi-glissement → spotlight décalé à droite, hors-cadre. On attend la + // fin RÉELLE de la transition (transitionEnd) avant de re-mesurer et de + // démarrer le chapitre ; le setTimeout reste un filet de sécurité si + // l'event ne se déclenche pas (écran déjà stable, pas de transition). + let started = false; + const launch = () => { + if (started) return; + started = true; + rChest(); + setTimeout(() => startChapter('inventory'), 50); + }; + const unsub = navigation.addListener('transitionEnd', launch); + const t = setTimeout(launch, 500); + return () => { unsub(); clearTimeout(t); }; + }, [pendingChapterId, startChapter, navigation, rChest]), ); const inventory = user?.inventory ?? []; From 2760091df61b725b01b6894904cfe62e6bcaa132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Mon, 13 Jul 2026 12:31:43 +0200 Subject: [PATCH 26/31] fix(ux): lisibilite des muscles secondaires, popup de sauvegarde de seance et filtres de trophees V2 --- .../src/components/common/ActionSheetModal.js | 13 +- .../workouts/SaveWorkoutPromptModal.js | 161 +++++++++++++++ front/src/data/exerciseCatalog.js | 6 +- front/src/data/trophyCatalog.js | 13 +- front/src/data/tutorialChapters.js | 2 +- front/src/data/workoutTemplates.js | 2 +- front/src/screens/Profile/TrophyRoomScreen.js | 49 ++--- .../Workouts/ManualWorkoutCreatorScreen.js | 47 +++-- .../screens/Workouts/WorkoutBuilderScreen.js | 191 +++++++++++++++--- .../src/screens/Workouts/WorkoutListScreen.js | 26 ++- front/src/services/savedWorkouts.service.js | 7 +- 11 files changed, 433 insertions(+), 84 deletions(-) create mode 100644 front/src/components/workouts/SaveWorkoutPromptModal.js diff --git a/front/src/components/common/ActionSheetModal.js b/front/src/components/common/ActionSheetModal.js index ef3f7d2..9e186d5 100644 --- a/front/src/components/common/ActionSheetModal.js +++ b/front/src/components/common/ActionSheetModal.js @@ -1,5 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Colors } from '../../constants/theme'; // ─── ActionSheetModal ───────────────────────────────────────────────────────── @@ -16,6 +17,8 @@ import { Colors } from '../../constants/theme'; // onClose () => void — appelé après tout choix (y compris Annuler) export default function ActionSheetModal({ visible, title, options = [], cancelLabel = 'Annuler', onClose }) { + const insets = useSafeAreaInsets(); + const handlePress = (opt) => { onClose?.(); opt.onPress?.(); @@ -23,7 +26,7 @@ export default function ActionSheetModal({ visible, title, options = [], cancelL return ( - + {!!title && {title}} @@ -52,7 +55,12 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: 'rgba(0,0,0,0.82)', justifyContent: 'flex-end', - padding: 12, + paddingHorizontal: 12, + // zIndex/elevation explicites : garantit que la feuille passe au-dessus de + // tout autre contenu positionné (barre d'onglets, headers) — notamment sur + // web où le portail du Modal n'a pas de z-index par défaut. + zIndex: 999, + elevation: 999, }, card: { width: '100%', @@ -63,7 +71,6 @@ const styles = StyleSheet.create({ paddingTop: 18, paddingHorizontal: 8, paddingBottom: 8, - marginBottom: 8, }, title: { color: Colors.textMuted, diff --git a/front/src/components/workouts/SaveWorkoutPromptModal.js b/front/src/components/workouts/SaveWorkoutPromptModal.js new file mode 100644 index 0000000..ac8ae0b --- /dev/null +++ b/front/src/components/workouts/SaveWorkoutPromptModal.js @@ -0,0 +1,161 @@ +import React from 'react'; +import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; + +// ─── SaveWorkoutPromptModal ─────────────────────────────────────────────────── +// Interception du lancement d'une séance sur-mesure (WorkoutBuilderScreen) : +// avant d'ouvrir le chrono, propose de sauvegarder la séance générée comme +// template réutilisable. Deux CTA de poids égal (contrairement à +// ConfirmModal/InfoModal qui n'ont qu'une seule action principale) + une +// icône de fermeture pour annuler sans choisir. +// +// Props : +// visible bool +// onSaveAndLaunch () => void — sauvegarde le template PUIS lance la séance +// onLaunchWithoutSave () => void — lance directement, aucun template créé +// onDismiss () => void — ferme la popup sans lancer la séance +// saving bool — désactive les actions pendant la sauvegarde + +export default function SaveWorkoutPromptModal({ + visible, onSaveAndLaunch, onLaunchWithoutSave, onDismiss, saving = false, +}) { + return ( + + + + + + + + + + + + Sauvegarder cette séance ? + + Souhaites-tu sauvegarder cette séance sur-mesure pour la retrouver plus tard ? + + + + + + {saving ? 'Sauvegarde...' : 'Sauvegarder et lancer la séance'} + + + + + + Lancer sans sauvegarder + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: Colors.bgDeep2, + borderRadius: 22, + borderWidth: 1, + borderColor: `${Colors.primary}38`, + padding: 28, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + closeBtn: { + position: 'absolute', + top: 16, + right: 16, + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: Colors.glassBg, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + }, + iconWrap: { + width: 60, + height: 60, + borderRadius: 18, + borderWidth: 1, + borderColor: `${Colors.primary}45`, + backgroundColor: `${Colors.primary}1A`, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 18, + }, + title: { + color: Colors.textPrimary, + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 10, + textAlign: 'center', + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 24, + }, + primaryBtn: { + flexDirection: 'row', + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 10, + backgroundColor: Colors.primary, + shadowColor: Colors.primary, + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + primaryTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + secondaryBtn: { + flexDirection: 'row', + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: Colors.borderDim, + }, + secondaryTxt: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + btnDisabled: { opacity: 0.5 }, +}); diff --git a/front/src/data/exerciseCatalog.js b/front/src/data/exerciseCatalog.js index 1c05a29..317d3cf 100644 --- a/front/src/data/exerciseCatalog.js +++ b/front/src/data/exerciseCatalog.js @@ -133,7 +133,6 @@ export const BUILTIN_CATALOG = [ exo('Élévations latérales unilatérales poulie', 'epaules', 'Deltoïde latéral', [], ['Câble'], 'intermediaire'), exo('Oiseau haltères', 'epaules', 'Deltoïde postérieur', ['Trapèzes', 'Rhomboïdes'], ['Haltères'], 'intermediaire', { videoQuery: 'oiseau haltères deltoïde postérieur' }), exo('Oiseau buste penché', 'epaules', 'Deltoïde postérieur', ['Trapèzes'], ['Haltères'], 'intermediaire'), - exo('Oiseau machine', 'epaules', 'Deltoïde postérieur', ['Trapèzes'], ['Machine'], 'debutant'), exo('Reverse pec deck', 'epaules', 'Deltoïde postérieur', ['Trapèzes'], ['Machine'], 'debutant'), exo('Face pull', 'epaules', 'Deltoïde postérieur', ['Trapèzes', 'Rhomboïdes'], ['Câble'], 'debutant'), exo('Rowing menton', 'epaules', 'Deltoïde latéral', ['Trapèzes'], ['Barre'], 'intermediaire'), @@ -162,6 +161,7 @@ export const BUILTIN_CATALOG = [ exo('Triceps poulie corde', 'bras', 'Triceps', [], ['Câble'], 'debutant'), exo('Triceps poulie inversé', 'bras', 'Triceps', [], ['Câble'], 'debutant'), exo('Dips', 'bras', 'Triceps', ['Pectoraux bas', 'Deltoïde antérieur'], ['Poids du corps'], 'intermediaire'), + exo('Dips assistés machine', 'bras', 'Triceps', ['Pectoraux bas', 'Deltoïde antérieur'], ['Machine'], 'debutant'), exo('Dips bench', 'bras', 'Triceps', ['Deltoïde antérieur'], ['Poids du corps'], 'debutant'), exo('Pompes diamant', 'bras', 'Triceps', ['Pectoraux milieu'], ['Poids du corps'], 'intermediaire'), exo('Kickback haltère', 'bras', 'Triceps', [], ['Haltères'], 'debutant'), @@ -186,7 +186,7 @@ export const BUILTIN_CATALOG = [ exo('Step-up', 'jambes', 'Quadriceps', ['Fessiers'], ['Haltères'], 'debutant'), exo('Wall sit', 'jambes', 'Quadriceps', [], ['Poids du corps'], 'debutant'), exo('Leg extension', 'jambes', 'Quadriceps', [], ['Machine'], 'debutant'), - exo('Presse à cuisses', 'jambes', 'Quadriceps', ['Fessiers'], ['Machine'], 'debutant'), + exo('Leg Press', 'jambes', 'Quadriceps', ['Fessiers'], ['Machine'], 'debutant'), exo('Presse à cuisses inclinée', 'jambes', 'Quadriceps', ['Fessiers'], ['Machine'], 'debutant'), exo('Fentes marchées', 'jambes', 'Quadriceps', ['Fessiers', 'Ischios'], ['Haltères'], 'intermediaire'), exo('Fentes inversées', 'jambes', 'Quadriceps', ['Fessiers'], ['Haltères'], 'debutant'), @@ -348,7 +348,6 @@ export const BUILTIN_CATALOG = [ exo('Around the world plank', 'abdos', 'Transverse', ['Obliques'], ['Poids du corps'], 'intermediaire'), // ─── Variantes complémentaires ────────────────────────────────────────── (+12) - exo('Pec deck inversé', 'epaules', 'Deltoïde postérieur', ['Trapèzes'], ['Machine'], 'debutant'), exo('Développé poitrine machine convergente', 'pectoraux', 'Pectoraux milieu', ['Triceps'], ['Machine'], 'debutant'), exo('Pull-down poignée corde', 'dos', 'Grand dorsal', ['Biceps'], ['Câble'], 'debutant'), exo('Tractions assistées machine', 'dos', 'Grand dorsal', ['Biceps'], ['Machine'], 'debutant'), @@ -445,7 +444,6 @@ export const BUILTIN_CATALOG = [ exo('Walking lunge poids du corps', 'jambes', 'Quadriceps', ['Fessiers', 'Ischios'], ['Poids du corps'], 'debutant'), exo('Step-up latéral', 'jambes', 'Fessiers', ['Quadriceps'], ['Haltères'], 'debutant'), exo('Romanian deadlift unilatéral haltère', 'jambes', 'Ischios', ['Fessiers'], ['Haltères'], 'avance'), - exo('Single leg leg press', 'jambes', 'Quadriceps', ['Fessiers'], ['Machine'], 'debutant'), exo('Single leg curl debout', 'jambes', 'Ischios', [], ['Machine'], 'debutant'), exo('Goblet squat plate', 'jambes', 'Quadriceps', ['Fessiers'], ['Poids du corps'], 'debutant'), exo('Cable squat', 'jambes', 'Quadriceps', ['Fessiers'], ['Câble'], 'debutant'), diff --git a/front/src/data/trophyCatalog.js b/front/src/data/trophyCatalog.js index 4d302e6..05f31b3 100644 --- a/front/src/data/trophyCatalog.js +++ b/front/src/data/trophyCatalog.js @@ -264,11 +264,16 @@ export const TROPHY_CATEGORIES = [ { id: 'special', label: 'Spécial', icon: 'gift', color: '#FE7439' }, ]; +// Chaque catégorie (locale + backend via BACKEND_CATEGORY_MAP) doit apparaître +// dans exactement un onglet ci-dessous — pas de doublon, pas de trou — pour que +// "Tous" et la somme des onglets filtrés restent toujours cohérents. export const TROPHY_FILTER_TABS = [ - { id: 'all', label: 'Tous' }, - { id: 'force', label: 'Force', categories: ['force', 'corps'] }, - { id: 'endurance', label: 'Endurance', categories: ['heritage', 'exploration', 'regularite'] }, - { id: 'special', label: 'Spécial', categories: ['secret', 'social', 'special'] }, + { id: 'all', label: 'Tous' }, + { id: 'force', label: 'Force', categories: ['force', 'corps'] }, + { id: 'endurance', label: 'Endurance', categories: ['heritage', 'exploration', 'regularite'] }, + { id: 'social', label: 'Social & Multi', categories: ['social'] }, + { id: 'collection', label: 'Coffres & Raretés', categories: ['collection'] }, + { id: 'special', label: 'Spécial', categories: ['secret', 'special'] }, ]; // ─── Helper: évalue le catalogue avec overrides (godMode console) ───────────── diff --git a/front/src/data/tutorialChapters.js b/front/src/data/tutorialChapters.js index 66255f8..839a35f 100644 --- a/front/src/data/tutorialChapters.js +++ b/front/src/data/tutorialChapters.js @@ -166,7 +166,7 @@ export const TUTORIAL_CHAPTERS = [ { key: 'trophies_filters', title: 'Filtrer par Catégorie', - body: "Les onglets 'Force', 'Endurance' et 'Spécial' filtrent les trophées. Les trophées verrouillés montrent leur condition.", + body: "Fais défiler les onglets 'Force', 'Endurance', 'Social & Multi', 'Coffres & Raretés' et 'Spécial' pour filtrer les trophées. Les trophées verrouillés montrent leur condition.", targetKey: 'trophies_filters', position: 'bottom', scrollY: 0, }, { diff --git a/front/src/data/workoutTemplates.js b/front/src/data/workoutTemplates.js index cf45fad..8592a50 100644 --- a/front/src/data/workoutTemplates.js +++ b/front/src/data/workoutTemplates.js @@ -173,7 +173,7 @@ export const TEMPLATES = [ videoQuery: 'soulevé de terre roumain', }), buildExercise({ - name: 'Presse à cuisses', + name: 'Leg Press', targetMuscle: 'Quadriceps', secondaryMuscles: ['Fessiers'], equipment: ['Machine'], diff --git a/front/src/screens/Profile/TrophyRoomScreen.js b/front/src/screens/Profile/TrophyRoomScreen.js index 0b25209..c907a05 100644 --- a/front/src/screens/Profile/TrophyRoomScreen.js +++ b/front/src/screens/Profile/TrophyRoomScreen.js @@ -226,12 +226,9 @@ export default function TrophyRoomScreen({ navigation }) { const allCategories = useMemo(() => [...TROPHY_CATEGORIES, COLLECTION_CATEGORY], []); - // Rattache "Collection" au filtre "Spécial", sans muter les données source. - const filterTabs = useMemo(() => TROPHY_FILTER_TABS.map((tab) => ( - tab.id === 'special' && tab.categories - ? { ...tab, categories: [...tab.categories, 'collection'] } - : tab - )), []); + // TROPHY_FILTER_TABS couvre déjà nativement "collection" via son propre + // onglet dédié ("Coffres & Raretés") — plus besoin de patch ad-hoc ici. + const filterTabs = TROPHY_FILTER_TABS; const visibleCategories = useMemo(() => { if (activeFilter === 'all') return allCategories; @@ -294,21 +291,27 @@ export default function TrophyRoomScreen({ navigation }) { - {/* Filter tabs */} - - {filterTabs.map((tab) => { - const active = activeFilter === tab.id; - return ( - setFilter(tab.id)} - activeOpacity={0.75} - > - {tab.label} - - ); - })} + {/* Filter tabs — scroll horizontal (6 onglets ne tiennent plus à plat) */} + + + {filterTabs.map((tab) => { + const active = activeFilter === tab.id; + return ( + setFilter(tab.id)} + activeOpacity={0.75} + > + {tab.label} + + ); + })} + { const shouldGoBack = infoModal?.onCloseNav; @@ -214,7 +227,7 @@ export default function ManualWorkoutCreatorScreen({ navigation }) { > - Créer une séance + {editWorkout ? 'Modifier la séance' : 'Créer une séance'} diff --git a/front/src/screens/Workouts/WorkoutBuilderScreen.js b/front/src/screens/Workouts/WorkoutBuilderScreen.js index 2495497..c801424 100644 --- a/front/src/screens/Workouts/WorkoutBuilderScreen.js +++ b/front/src/screens/Workouts/WorkoutBuilderScreen.js @@ -2,6 +2,7 @@ import React, { useState, useMemo, useCallback } from 'react'; import { View, Text, + TextInput, TouchableOpacity, ScrollView, StyleSheet, @@ -19,8 +20,10 @@ import MuscleHierarchyPicker from '../../components/workouts/MuscleHierarchyPick import { useCustomExercises } from '../../context/CustomExercisesContext'; import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext'; import { useSavedWorkouts } from '../../context/SavedWorkoutsContext'; +import { useToast } from '../../context/ToastContext'; import { generateWorkout } from '../../data/exerciseCatalog'; import InfoModal from '../../components/common/InfoModal'; +import SaveWorkoutPromptModal from '../../components/workouts/SaveWorkoutPromptModal'; const DURATIONS = [ { id: 30, label: '30 min' }, @@ -30,18 +33,41 @@ const DURATIONS = [ { id: 90, label: '90 min' }, ]; -export default function WorkoutBuilderScreen({ navigation }) { +const DEFAULT_WORKOUT_NAME = 'Séance personnalisée'; + +export default function WorkoutBuilderScreen({ navigation, route }) { const { items: customExercises } = useCustomExercises(); const { loadWorkout } = useWorkoutInProgress(); - const { create: createSavedWorkout } = useSavedWorkouts(); + const { create: createSavedWorkout, update: updateSavedWorkout, remove: removeSavedWorkout } = useSavedWorkouts(); + const { showToast } = useToast(); + + // Édition d'une séance existante (venant de WorkoutListScreen → "Modifier") : + // pré-remplit les critères et le libellé, et bascule le toggle Sauver sur + // "déjà sauvegardée" puisqu'on modifie une entrée qui existe déjà. + const editWorkout = route?.params?.editWorkout ?? null; + const editCriteria = editWorkout?.criteria ?? null; - const [subMuscles, setSubMuscles] = useState([]); - const [equipment, setEquipment] = useState([]); - const [level, setLevel] = useState(''); - const [duration, setDuration] = useState(60); + const [subMuscles, setSubMuscles] = useState(editCriteria?.subMuscles ?? []); + const [equipment, setEquipment] = useState(editCriteria?.equipment ?? []); + const [level, setLevel] = useState(editCriteria?.level ?? ''); + const [duration, setDuration] = useState(editCriteria?.durationMin ?? 60); const [regenKey, setRegenKey] = useState(0); const [saving, setSaving] = useState(false); const [infoModal, setInfoModal] = useState(null); // { title, body } + const [savePromptVisible, setSavePromptVisible] = useState(false); + const [workoutName, setWorkoutName] = useState(editWorkout?.name || DEFAULT_WORKOUT_NAME); + // Non-null ⇔ la séance actuellement en aperçu correspond exactement à une + // entrée déjà sauvegardée (icône "Sauver" pleine). Invalidé dès que les + // critères ou l'aperçu changent, puisque le contenu ne correspond plus. + const [savedWorkoutId, setSavedWorkoutId] = useState(editWorkout?.id ?? null); + // Id de l'entrée à METTRE À JOUR (plutôt que dupliquer) à la prochaine + // sauvegarde — distinct de savedWorkoutId : reste valide même après un + // changement de critères (l'entrée d'origine existe toujours en storage), + // et n'est effacé que si cette entrée est explicitement supprimée (toggle + // off), pour ne jamais tenter de mettre à jour un id déjà supprimé. + const [editingId, setEditingId] = useState(editWorkout?.id ?? null); + + const invalidateSaved = useCallback(() => setSavedWorkoutId(null), []); const toggleArr = useCallback((arr, value) => ( arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] @@ -62,33 +88,93 @@ export default function WorkoutBuilderScreen({ navigation }) { const onRegenerate = useCallback(() => { setRegenKey((k) => k + 1); - }, []); + invalidateSaved(); + }, [invalidateSaved]); - const onLaunch = useCallback(() => { + // Lance réellement le chrono (navigation vers l'écran de séance active). + const launchWorkout = useCallback(() => { if (!preview || preview.exercises.length === 0) return; loadWorkout(preview); if (navigation) navigation.navigate('Workout', { workout: preview }); }, [preview, loadWorkout, navigation]); - const onSave = useCallback(async () => { + // Bouton "Lancer la séance" : si l'aperçu courant est déjà sauvegardé (toggle + // actif), on lance directement — inutile de redemander. Sinon on intercepte + // pour proposer la sauvegarde avant d'ouvrir le chrono. + const onLaunch = useCallback(() => { + if (!preview || preview.exercises.length === 0) return; + if (savedWorkoutId) { launchWorkout(); return; } + setSavePromptVisible(true); + }, [preview, savedWorkoutId, launchWorkout]); + + // Sauvegarde (création OU mise à jour si editingId pointe vers une entrée + // existante) l'aperçu courant sous workoutName, avec ses critères de + // génération pour permettre une future modification pré-remplie. + const persistCurrentPreview = useCallback(async () => { + const trimmedName = workoutName.trim() || DEFAULT_WORKOUT_NAME; + const payload = { + name: trimmedName, + description: preview.description, + exercises: preview.exercises, + criteria: { subMuscles, equipment, level, durationMin: duration }, + }; + if (editingId) { + return updateSavedWorkout(editingId, payload); + } + return createSavedWorkout(payload); + }, [workoutName, preview, subMuscles, equipment, level, duration, editingId, createSavedWorkout, updateSavedWorkout]); + + // Bouton "Sauver" du bandeau d'aperçu — toggle : sauvegarde si absent, + // retire des séances si déjà sauvegardé (l'icône passe pleine ↔ contour). + const onToggleSave = useCallback(async () => { + if (!preview || preview.exercises.length === 0) return; + setSaving(true); + try { + if (savedWorkoutId) { + await removeSavedWorkout(savedWorkoutId); + setSavedWorkoutId(null); + setEditingId(null); + showToast('Retirée de tes séances', 'success'); + } else { + const item = await persistCurrentPreview(); + setSavedWorkoutId(item.id); + setEditingId(item.id); + showToast('Séance sauvegardée', 'success'); + } + } catch (e) { + setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Action impossible' }); + } finally { + setSaving(false); + } + }, [preview, savedWorkoutId, persistCurrentPreview, removeSavedWorkout, showToast]); + + const handleSaveAndLaunch = useCallback(async () => { if (!preview || preview.exercises.length === 0) return; setSaving(true); try { - await createSavedWorkout({ - name: preview.name, - description: preview.description, - exercises: preview.exercises, - }); - setInfoModal({ - title: 'Sauvegardé', - body: `"${preview.name}" est dans tes séances. Retrouve-la dans la page Séances.`, - }); + const item = await persistCurrentPreview(); + setSavedWorkoutId(item.id); + setEditingId(item.id); } catch (e) { - setInfoModal({ title: 'Erreur', body: e && e.message ? e.message : 'Sauvegarde impossible' }); + // La séance se lance quand même : un échec de sauvegarde ne doit jamais + // empêcher l'entraînement. On informe juste via un toast non bloquant + // (l'utilisateur pourra retenter avec le bouton "Sauver" dédié). + showToast(e && e.message ? e.message : 'Sauvegarde impossible, séance lancée sans template.', 'error'); } finally { setSaving(false); + setSavePromptVisible(false); + launchWorkout(); } - }, [preview, createSavedWorkout]); + }, [preview, persistCurrentPreview, launchWorkout, showToast]); + + const handleLaunchWithoutSave = useCallback(() => { + setSavePromptVisible(false); + launchWorkout(); + }, [launchWorkout]); + + const handleDismissSavePrompt = useCallback(() => { + setSavePromptVisible(false); + }, []); const totalSelectedCount = subMuscles.length; @@ -101,7 +187,7 @@ export default function WorkoutBuilderScreen({ navigation }) { > - Crée ta séance + {editWorkout ? 'Modifier ta séance' : 'Crée ta séance'} @@ -119,7 +205,7 @@ export default function WorkoutBuilderScreen({ navigation }) { { setSubMuscles(v); invalidateSaved(); }} showSelectAll />
@@ -131,7 +217,7 @@ export default function WorkoutBuilderScreen({ navigation }) { key={eq.id} label={eq.label} selected={equipment.includes(eq.label)} - onPress={() => setEquipment((s) => toggleArr(s, eq.label))} + onPress={() => { setEquipment((s) => toggleArr(s, eq.label)); invalidateSaved(); }} /> ))} @@ -144,7 +230,7 @@ export default function WorkoutBuilderScreen({ navigation }) { key={lv.id} label={lv.label} selected={level === lv.id} - onPress={() => setLevel(level === lv.id ? '' : lv.id)} + onPress={() => { setLevel(level === lv.id ? '' : lv.id); invalidateSaved(); }} /> ))} @@ -157,12 +243,24 @@ export default function WorkoutBuilderScreen({ navigation }) { key={d.id} label={d.label} selected={duration === d.id} - onPress={() => setDuration(d.id)} + onPress={() => { setDuration(d.id); invalidateSaved(); }} /> ))}
+
+ +
+ @@ -176,14 +274,24 @@ export default function WorkoutBuilderScreen({ navigation }) { {preview && preview.exercises.length > 0 ? ( - - {saving ? '...' : 'Sauver'} + + + {saving ? '...' : savedWorkoutId ? 'Sauvée' : 'Sauver'} + setInfoModal(null)} /> + + ); } @@ -361,12 +477,28 @@ const styles = StyleSheet.create({ marginLeft: 6, }, previewActionDisabled: { opacity: 0.5 }, + previewActionSaved: { + backgroundColor: Colors.primary, + borderColor: Colors.primary, + }, previewActionText: { color: Colors.primary, fontSize: 12, fontWeight: '700', marginLeft: 4, }, + previewActionTextSaved: { color: '#fff' }, + nameInput: { + backgroundColor: Colors.card, + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + paddingHorizontal: 14, + paddingVertical: 12, + color: Colors.textPrimary, + fontSize: 14, + fontWeight: '600', + }, previewEmpty: { paddingVertical: 18, alignItems: 'center', @@ -400,6 +532,7 @@ const styles = StyleSheet.create({ fontWeight: '600', }, previewItemMuscle: { + color: Colors.textSecondary, fontSize: 12, marginTop: 2, }, diff --git a/front/src/screens/Workouts/WorkoutListScreen.js b/front/src/screens/Workouts/WorkoutListScreen.js index b7c0b04..92c8051 100644 --- a/front/src/screens/Workouts/WorkoutListScreen.js +++ b/front/src/screens/Workouts/WorkoutListScreen.js @@ -21,6 +21,7 @@ import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; import MultiLobbyModal from '../../components/workouts/MultiLobbyModal'; import ConfirmModal from '../../components/common/ConfirmModal'; import InfoModal from '../../components/common/InfoModal'; +import ActionSheetModal from '../../components/common/ActionSheetModal'; // Page d'entrée "Séances". // Header : titre + 2 icônes (Mes exercices, Créer un exercice). @@ -115,6 +116,7 @@ export default function WorkoutListScreen({ navigation, route }) { }, [route?.params?.pendingLobbyId, navigation]); const [deleteSavedTarget, setDeleteSavedTarget] = useState(null); + const [actionSheetTarget, setActionSheetTarget] = useState(null); const [errorInfo, setErrorInfo] = useState(null); // ─── Tutorial ───────────────────────────────────────────────────────────── @@ -207,7 +209,19 @@ export default function WorkoutListScreen({ navigation, route }) { setConfirmItem(null); }, [confirmItem, navigation, loadWorkout]); - const onLongPressSaved = useCallback((saved) => setDeleteSavedTarget(saved), []); + const onLongPressSaved = useCallback((saved) => setActionSheetTarget(saved), []); + + // "Modifier" route vers l'écran de création d'ORIGINE de la séance, pour que + // l'interface d'édition soit rigoureusement identique à celle de création : + // WorkoutBuilder pour toute séance sur-mesure (isManual: false), même une + // séance ancienne créée avant l'ajout du champ criteria (elle rouvrira alors + // l'écran sur-mesure avec des critères vierges plutôt qu'un éditeur différent) ; + // ManualWorkoutCreator uniquement pour les séances explicitement manuelles. + const onEditSaved = useCallback((saved) => { + if (!navigation) return; + const target = saved.isManual ? 'ManualWorkoutCreator' : 'WorkoutBuilder'; + navigation.navigate(target, { editWorkout: saved }); + }, [navigation]); const confirmDeleteSaved = useCallback(async () => { const saved = deleteSavedTarget; @@ -389,6 +403,16 @@ export default function WorkoutListScreen({ navigation, route }) { onReady={handleMultiReady} /> + onEditSaved(actionSheetTarget) }, + { label: 'Supprimer', destructive: true, onPress: () => setDeleteSavedTarget(actionSheetTarget) }, + ]} + onClose={() => setActionSheetTarget(null)} + /> + Date: Mon, 13 Jul 2026 14:32:50 +0200 Subject: [PATCH 27/31] chore(refactor): grand nettoyage elite pre-prod (-851 lines, suppression fichiers morts, centralisation theme et barrels) --- back/services/user.service.js | 9 +- front/App.js | 2 +- front/src/components/brand/AthlyLogo.js | 95 ----- front/src/components/buttons/PrimaryButton.js | 38 -- front/src/components/cards/Card.js | 29 -- front/src/components/cards/ExerciseCard.js | 4 +- front/src/components/cards/SetRow.js | 2 +- front/src/components/cards/StatBox.js | 36 -- front/src/components/cards/WorkoutItem.js | 38 -- .../src/components/common/ActionSheetModal.js | 4 +- front/src/components/common/ConfirmModal.js | 75 +--- front/src/components/common/InfoModal.js | 78 +--- .../components/common/NotificationBanner.js | 5 +- front/src/components/common/QuestToast.js | 4 +- .../src/components/common/WeightEntryModal.js | 2 +- front/src/components/common/index.js | 13 + front/src/components/common/modalStyles.js | 69 +++ front/src/components/home/DailyQuestsCard.js | 8 +- .../components/home/RecoveryRitualsCard.js | 2 +- .../components/inventory/ChestOpeningModal.js | 2 +- .../components/profile/AchievementShowcase.js | 10 +- .../src/components/profile/ActivityHeatmap.js | 2 +- .../src/components/profile/BirthdatePicker.js | 2 +- .../components/profile/BirthdayCelebration.js | 2 +- front/src/components/profile/BirthdayModal.js | 2 +- front/src/components/profile/BorderPicker.js | 4 +- .../src/components/profile/EmberParticles.js | 3 +- front/src/components/profile/HeroLevelCard.js | 2 +- front/src/components/profile/LevelUpModal.js | 18 +- .../profile/RecordsShowcasePicker.js | 6 +- front/src/components/profile/StreakBadge.js | 10 +- .../components/profile/TitlePickerModal.js | 10 +- front/src/components/profile/TrophyGrid.js | 2 +- .../components/social/ActivityFeedModal.js | 4 +- front/src/components/social/AddFriendModal.js | 2 +- .../components/social/ExercisePickerModal.js | 2 +- .../components/social/FriendPreviewModal.js | 2 +- .../social/FriendshipLevelUpModal.js | 4 +- .../components/stats/WeightReminderCheck.js | 2 +- .../components/stats/WeightReminderModal.js | 2 +- front/src/components/stats/XPProgressBar.js | 2 +- .../components/tutorial/TutorialOverlay.js | 4 +- .../src/components/typography/SectionTitle.js | 21 - front/src/components/ui/AppToast.js | 9 +- .../src/components/web/DesktopInstallPage.js | 11 +- .../components/workouts/AddExerciseSheet.js | 2 +- .../src/components/workouts/ExerciseHeader.js | 2 +- .../components/workouts/LobbyInviteCheck.js | 2 +- .../components/workouts/LobbyInviteModal.js | 2 +- .../workouts/LobbyWaitingOverlay.js | 2 +- .../components/workouts/MultiLobbyModal.js | 10 +- .../src/components/workouts/MultiLootModal.js | 2 +- .../workouts/MuscleHierarchyPicker.js | 4 +- front/src/components/workouts/SetTable.js | 2 +- .../workouts/ShortSessionWarningModal.js | 4 +- .../src/components/workouts/WorkoutHeader.js | 72 ---- .../components/workouts/WorkoutRecapModal.js | 14 +- front/src/constants/theme.js | 7 +- front/src/context/CustomExercisesContext.js | 2 +- front/src/context/QuestContext.js | 2 +- front/src/context/SavedWorkoutsContext.js | 2 +- front/src/context/TutorialContext.js | 4 +- front/src/context/WorkoutInProgressContext.js | 4 +- front/src/context/WorkoutLogsContext.js | 4 +- front/src/hooks/index.js | 11 + front/src/hooks/useWorkoutState.js | 14 +- front/src/navigation/index.js | 4 +- front/src/screens/Auth/AuthScreen.js | 396 ------------------ .../screens/Auth/EmailVerificationScreen.js | 4 +- .../src/screens/Auth/ForgotPasswordScreen.js | 10 +- front/src/screens/Auth/LoginScreen.js | 6 +- front/src/screens/Auth/RegisterScreen.js | 22 +- front/src/screens/Home/HomeScreen.js | 8 +- .../src/screens/Profile/EditProfileScreen.js | 10 +- front/src/screens/Profile/InventoryScreen.js | 10 +- front/src/screens/Profile/ProfileScreen.js | 20 +- .../src/screens/Profile/RankRoadmapScreen.js | 18 +- front/src/screens/Profile/SettingsScreen.js | 38 +- front/src/screens/Profile/TrophyRoomScreen.js | 16 +- .../src/screens/Social/FriendProfileScreen.js | 8 +- front/src/screens/Social/SocialScreen.js | 10 +- front/src/screens/Stats/StatsScreen.js | 14 +- .../screens/Workouts/CustomExercisesScreen.js | 8 +- .../screens/Workouts/EditExerciseScreen.js | 6 +- .../screens/Workouts/ExerciseDetailScreen.js | 2 +- .../screens/Workouts/ExerciseStatsScreen.js | 2 +- .../Workouts/ManualWorkoutCreatorScreen.js | 10 +- .../screens/Workouts/WorkoutBuilderScreen.js | 6 +- front/src/screens/Workouts/WorkoutScreen.js | 16 +- front/src/services/exercise.service.js | 9 - front/src/services/index.js | 25 ++ front/src/services/profile.service.js | 7 + front/src/services/workout.service.js | 28 -- front/src/services/workouts.service.js | 36 ++ front/src/styles/global.js | 131 ------ 95 files changed, 425 insertions(+), 1276 deletions(-) delete mode 100644 front/src/components/brand/AthlyLogo.js delete mode 100644 front/src/components/buttons/PrimaryButton.js delete mode 100644 front/src/components/cards/Card.js delete mode 100644 front/src/components/cards/StatBox.js delete mode 100644 front/src/components/cards/WorkoutItem.js create mode 100644 front/src/components/common/index.js create mode 100644 front/src/components/common/modalStyles.js delete mode 100644 front/src/components/typography/SectionTitle.js delete mode 100644 front/src/components/workouts/WorkoutHeader.js create mode 100644 front/src/hooks/index.js delete mode 100644 front/src/screens/Auth/AuthScreen.js delete mode 100644 front/src/services/exercise.service.js create mode 100644 front/src/services/index.js delete mode 100644 front/src/services/workout.service.js create mode 100644 front/src/services/workouts.service.js delete mode 100644 front/src/styles/global.js diff --git a/back/services/user.service.js b/back/services/user.service.js index 48f09cf..973c9ea 100644 --- a/back/services/user.service.js +++ b/back/services/user.service.js @@ -64,14 +64,9 @@ class UserService { async deleteAccount(userId) { const id = new Types.ObjectId(userId); - const exerciseResult = await ExerciseRecord.deleteMany({ user: id }); - console.log(`🗑️ [deleteAccount] ExerciseRecords supprimés : ${exerciseResult.deletedCount}`); - - const workoutResult = await Workout.deleteMany({ user: id }); - console.log(`🗑️ [deleteAccount] Workouts supprimés : ${workoutResult.deletedCount}`); - + await ExerciseRecord.deleteMany({ user: id }); + await Workout.deleteMany({ user: id }); await User.findByIdAndDelete(id); - console.log(`🗑️ [deleteAccount] Utilisateur supprimé : ${userId}`); } /** diff --git a/front/App.js b/front/App.js index efc7cbb..e45555d 100644 --- a/front/App.js +++ b/front/App.js @@ -6,7 +6,7 @@ import { ToastProvider } from './src/context/ToastContext'; import AppNavigator from './src/navigation'; import DesktopInstallPage from './src/components/web/DesktopInstallPage'; -import ErrorBoundary from './src/components/common/ErrorBoundary'; +import { ErrorBoundary } from './src/components/common'; const isDesktopWeb = Platform.OS === 'web' && diff --git a/front/src/components/brand/AthlyLogo.js b/front/src/components/brand/AthlyLogo.js deleted file mode 100644 index 533c679..0000000 --- a/front/src/components/brand/AthlyLogo.js +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { Image, Animated, Easing, StyleSheet } from 'react-native'; - -// Référentiel officiel des assets -// type='full' + color='orange' → logo-orange.png (logo complet, couleur principale) -// type='full' + color='violet' → logo-violet.png (logo complet, gamification) -// type='icon' + color='orange' → minimalist-logo-orange.png (icône "A" seule) -// type='icon' + color='violet' → minimalist-logo-violet.png (icône "A" seule) -const SOURCES = { - full: { - orange: require('../../../assets/logo-orange.png'), - violet: require('../../../assets/logo-violet.png'), - }, - icon: { - orange: require('../../../assets/minimalist-logo-orange.png'), - violet: require('../../../assets/minimalist-logo-violet.png'), - }, -}; - -// Props : -// type — 'full' (logo complet texte+icône) | 'icon' (icône "A" seule) -// color — 'orange' | 'violet' -// width — largeur explicite pour type="full" (défaut 180) -// height — hauteur explicite pour type="full" (défaut : width × 0.67) -// size — taille carrée pour type="icon" (défaut 40) -// animate — FadeInUp au montage (AuthScreen) -// pulse — respiration légère en boucle (états de chargement) -// style — styles de positionnement uniquement (margin, alignSelf…) -export default function AthlyLogo({ - type = 'full', - color = 'orange', - width = 180, - height, - size = 40, - animate = false, - pulse = false, - style, -}) { - const opacity = useRef(new Animated.Value(animate ? 0 : 1)).current; - const translateY = useRef(new Animated.Value(animate ? 14 : 0)).current; - const scale = useRef(new Animated.Value(1)).current; - - // FadeInUp — déclenché une seule fois au montage - useEffect(() => { - if (!animate) return; - Animated.parallel([ - Animated.timing(opacity, { - toValue: 1, duration: 520, delay: 80, - easing: Easing.out(Easing.quad), useNativeDriver: true, - }), - Animated.timing(translateY, { - toValue: 0, duration: 520, delay: 80, - easing: Easing.out(Easing.cubic), useNativeDriver: true, - }), - ]).start(); - }, []); - - // Pulse loop — activé/désactivé par la prop `pulse` - useEffect(() => { - if (!pulse) { scale.setValue(1); return; } - const loop = Animated.loop( - Animated.sequence([ - Animated.timing(scale, { toValue: 1.07, duration: 900, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), - Animated.timing(scale, { toValue: 1, duration: 900, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), - ]), - ); - loop.start(); - return () => loop.stop(); - }, [pulse]); - - const source = (SOURCES[type] || SOURCES.full)[color] || SOURCES.full.orange; - - const bounds = - type === 'icon' - ? { width: size, height: size } - : { width, height: height ?? Math.round(width * 0.67) }; - - return ( - - - - ); -} diff --git a/front/src/components/buttons/PrimaryButton.js b/front/src/components/buttons/PrimaryButton.js deleted file mode 100644 index a720a73..0000000 --- a/front/src/components/buttons/PrimaryButton.js +++ /dev/null @@ -1,38 +0,0 @@ -// ============================================================================================ -// Composant REUTILISABLE : PrimaryButton -// ============================================================================================ -// Bouton principal (orange) utilisé partout dans l'app. -// Props : -// - label : texte du bouton -// - onPress : fonction à exécuter au clic -// -// Avantages : -// ✔ Style centralisé -// ✔ Reutilisable sur plusieurs écrans -// ============================================================================================ - -import React from "react"; -import { TouchableOpacity, Text, StyleSheet } from "react-native"; - -export default function PrimaryButton({ label, onPress }) { - return ( - - {label} - - ); -} - -const styles = StyleSheet.create({ - button: { - backgroundColor: "#ff7a33", - paddingVertical: 16, - borderRadius: 12, - marginTop: 10, - alignItems: "center", - }, - text: { - color: "#fff", - fontWeight: "bold", - fontSize: 16, - }, -}); \ No newline at end of file diff --git a/front/src/components/cards/Card.js b/front/src/components/cards/Card.js deleted file mode 100644 index f9c2935..0000000 --- a/front/src/components/cards/Card.js +++ /dev/null @@ -1,29 +0,0 @@ -// ------------------------------------------------------------- -// Composant Card réutilisable -// ------------------------------------------------------------- -// Ce composant sert de conteneur "carte" pour afficher -// du contenu avec un style uniforme (fond, arrondi, padding, margin). -// Il utilise `props.children` pour englober n'importe quel contenu. -// ------------------------------------------------------------- - -import React from "react"; -import { View, StyleSheet } from "react-native"; -import { Colors } from "../../constants/theme"; - -export default function Card({ children, style }) { - return {children}; -} - -const styles = StyleSheet.create({ - card: { - backgroundColor: Colors.card, // Couleur de fond des cartes - padding: 20, // Padding interne - borderRadius: 16, // Arrondi des coins - marginBottom: 16, // Marge en bas - shadowColor: "#000", // Optionnel : ombre légère pour iOS - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, // Ombre pour Android - }, -}); \ No newline at end of file diff --git a/front/src/components/cards/ExerciseCard.js b/front/src/components/cards/ExerciseCard.js index f5fa7d8..60a7d07 100644 --- a/front/src/components/cards/ExerciseCard.js +++ b/front/src/components/cards/ExerciseCard.js @@ -147,7 +147,7 @@ const styles = StyleSheet.create({ cardInSuperset: { marginHorizontal: 10, marginBottom: 8, - backgroundColor: '#1f1f27', + backgroundColor: Colors.borderSubtle, }, row: { flexDirection: 'row', @@ -157,7 +157,7 @@ const styles = StyleSheet.create({ width: 52, height: 52, borderRadius: 12, - backgroundColor: '#0e0e12', + backgroundColor: Colors.cardInner, justifyContent: 'center', alignItems: 'center', marginRight: 14, diff --git a/front/src/components/cards/SetRow.js b/front/src/components/cards/SetRow.js index 81f8cb1..82ede21 100644 --- a/front/src/components/cards/SetRow.js +++ b/front/src/components/cards/SetRow.js @@ -8,7 +8,7 @@ import { } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; -import { haptics } from '../../services/haptics.service'; +import { haptics } from '../../services'; // Ligne de série : [-] SET | POIDS (KG) | REPS | VALIDER // diff --git a/front/src/components/cards/StatBox.js b/front/src/components/cards/StatBox.js deleted file mode 100644 index 661c807..0000000 --- a/front/src/components/cards/StatBox.js +++ /dev/null @@ -1,36 +0,0 @@ -// ============================================================================================ -// Composant StatBox -// Petite carte affichant une statistique : valeur + label -// ============================================================================================ - -import React from "react"; -import { View, Text, StyleSheet } from "react-native"; -import { Colors } from "../../constants/theme"; - -export default function StatBox({ value, label }) { - return ( - - {value} - {label} - - ); -} - -const styles = StyleSheet.create({ - box: { - backgroundColor: Colors.card, - width: "30%", - padding: 14, - borderRadius: 14, - }, - value: { - color: Colors.textPrimary, - fontSize: 22, - fontWeight: "bold", - }, - label: { - color: Colors.textSecondary, - marginTop: 4, - fontSize: 12, - }, -}); \ No newline at end of file diff --git a/front/src/components/cards/WorkoutItem.js b/front/src/components/cards/WorkoutItem.js deleted file mode 100644 index 7eb138b..0000000 --- a/front/src/components/cards/WorkoutItem.js +++ /dev/null @@ -1,38 +0,0 @@ -// ============================================================================================ -// Composant REUTILISABLE : WorkoutItem -// ============================================================================================ -// Carte affichant une séance : -// - titre (Push, Pull...) -// - informations complémentaires (nombre d'exercices, durée...) -// - action au clic (ouvrir la séance) -// ============================================================================================ -import React from "react"; -import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; -import { Colors } from "../../constants/theme"; - -export default function WorkoutItem({ title, info, onPress }) { - return ( - - {title} - {info} - - ); -} - -const styles = StyleSheet.create({ - card: { - backgroundColor: Colors.card, - padding: 18, - borderRadius: 14, - marginBottom: 12, - }, - title: { - color: Colors.textPrimary, - fontSize: 16, - fontWeight: "bold", - }, - info: { - color: Colors.textSecondary, - marginTop: 4, - }, -}); diff --git a/front/src/components/common/ActionSheetModal.js b/front/src/components/common/ActionSheetModal.js index ef3f7d2..34789e0 100644 --- a/front/src/components/common/ActionSheetModal.js +++ b/front/src/components/common/ActionSheetModal.js @@ -56,7 +56,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 18, borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', @@ -82,7 +82,7 @@ const styles = StyleSheet.create({ }, optionBtnLast: { marginBottom: 8 }, optionTxt: { color: Colors.textPrimary, fontSize: 15.5, fontWeight: '600' }, - optionTxtDestructive: { color: '#EF4444' }, + optionTxtDestructive: { color: Colors.destructive }, cancelBtn: { height: 50, justifyContent: 'center', diff --git a/front/src/components/common/ConfirmModal.js b/front/src/components/common/ConfirmModal.js index 04aa539..684808f 100644 --- a/front/src/components/common/ConfirmModal.js +++ b/front/src/components/common/ConfirmModal.js @@ -2,6 +2,7 @@ import React from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; +import { modalStyles } from './modalStyles'; // ─── ConfirmModal ───────────────────────────────────────────────────────────── // Modale de confirmation stylisée (fond sombre, carte, boutons) à utiliser à la @@ -24,25 +25,25 @@ export default function ConfirmModal({ confirmLabel, cancelLabel = 'Annuler', destructive = false, onConfirm, onCancel, }) { - const accent = destructive ? '#EF4444' : Colors.primary; + const accent = destructive ? Colors.destructive : Colors.primary; return ( - - - + + + - {title} - {body ? {body} : null} + {title} + {body ? {body} : null} - {confirmLabel} + {confirmLabel} @@ -55,63 +56,7 @@ export default function ConfirmModal({ } const styles = StyleSheet.create({ - backdrop: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.82)', - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 24, - }, - card: { - width: '100%', - backgroundColor: '#13131C', - borderRadius: 22, - borderWidth: 1, - padding: 28, - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 16 }, - shadowOpacity: 0.65, - shadowRadius: 32, - elevation: 20, - }, - iconWrap: { - width: 60, - height: 60, - borderRadius: 18, - borderWidth: 1, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 18, - }, - title: { - color: Colors.textPrimary, - fontSize: 18, - fontWeight: '800', - letterSpacing: -0.3, - marginBottom: 10, - textAlign: 'center', - }, - body: { - color: Colors.textSecondary, - fontSize: 14, - lineHeight: 21, - textAlign: 'center', - marginBottom: 24, - }, - confirmBtn: { - width: '100%', - height: 50, - borderRadius: 13, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 10, - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.40, - shadowRadius: 12, - elevation: 6, - }, - confirmTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, + confirmSpacing: { marginBottom: 10 }, cancelBtn: { width: '100%', height: 44, borderRadius: 13, justifyContent: 'center', alignItems: 'center' }, cancelTxt: { color: Colors.textMuted, fontSize: 14, fontWeight: '500' }, }); diff --git a/front/src/components/common/InfoModal.js b/front/src/components/common/InfoModal.js index 0aba99b..f7cba07 100644 --- a/front/src/components/common/InfoModal.js +++ b/front/src/components/common/InfoModal.js @@ -1,7 +1,8 @@ import React from 'react'; -import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; +import { View, Text, Modal, TouchableOpacity } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; +import { modalStyles } from './modalStyles'; // ─── InfoModal ──────────────────────────────────────────────────────────────── // Alerte à un seul bouton (information, erreur, validation simple) — remplace @@ -21,87 +22,28 @@ export default function InfoModal({ visible, icon = 'information-circle-outline', title, body, closeLabel = 'OK', destructive = false, onClose, }) { - const accent = destructive ? '#EF4444' : Colors.primary; + const accent = destructive ? Colors.destructive : Colors.primary; return ( - - - + + + - {title} - {body ? {body} : null} + {title} + {body ? {body} : null} - {closeLabel} + {closeLabel} ); } - -const styles = StyleSheet.create({ - backdrop: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.82)', - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 24, - }, - card: { - width: '100%', - backgroundColor: '#13131C', - borderRadius: 22, - borderWidth: 1, - padding: 28, - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 16 }, - shadowOpacity: 0.65, - shadowRadius: 32, - elevation: 20, - }, - iconWrap: { - width: 60, - height: 60, - borderRadius: 18, - borderWidth: 1, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 18, - }, - title: { - color: Colors.textPrimary, - fontSize: 18, - fontWeight: '800', - letterSpacing: -0.3, - marginBottom: 10, - textAlign: 'center', - }, - body: { - color: Colors.textSecondary, - fontSize: 14, - lineHeight: 21, - textAlign: 'center', - marginBottom: 24, - }, - closeBtn: { - width: '100%', - height: 50, - borderRadius: 13, - justifyContent: 'center', - alignItems: 'center', - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.40, - shadowRadius: 12, - elevation: 6, - }, - closeTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, -}); diff --git a/front/src/components/common/NotificationBanner.js b/front/src/components/common/NotificationBanner.js index 07cc357..f05ff1c 100644 --- a/front/src/components/common/NotificationBanner.js +++ b/front/src/components/common/NotificationBanner.js @@ -1,11 +1,12 @@ import React, { useEffect, useRef } from 'react'; import { View, Text, StyleSheet, Animated } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; const VARIANTS = { error: { icon: 'alert-circle', - color: '#FF4D4D', + color: Colors.error, bg: 'rgba(255, 77, 77, 0.10)', border: 'rgba(255, 77, 77, 0.30)', iconBg: 'rgba(255, 77, 77, 0.15)', @@ -26,7 +27,7 @@ const VARIANTS = { }, success: { icon: 'checkmark-circle', - color: '#44FF88', + color: Colors.success, bg: 'rgba(68, 255, 136, 0.10)', border: 'rgba(68, 255, 136, 0.28)', iconBg: 'rgba(68, 255, 136, 0.15)', diff --git a/front/src/components/common/QuestToast.js b/front/src/components/common/QuestToast.js index 3da6ee3..8c89d99 100644 --- a/front/src/components/common/QuestToast.js +++ b/front/src/components/common/QuestToast.js @@ -79,7 +79,7 @@ export default function QuestToast({ visible, questLabel, isBonus = false, onHid @@ -145,6 +145,6 @@ const styles = StyleSheet.create({ marginTop: 2, }, labelBonus: { - color: '#FFD700', + color: Colors.gold, }, }); diff --git a/front/src/components/common/WeightEntryModal.js b/front/src/components/common/WeightEntryModal.js index 80c61cb..a9c5d13 100644 --- a/front/src/components/common/WeightEntryModal.js +++ b/front/src/components/common/WeightEntryModal.js @@ -98,7 +98,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: `${Colors.primary}38`, diff --git a/front/src/components/common/index.js b/front/src/components/common/index.js new file mode 100644 index 0000000..c913598 --- /dev/null +++ b/front/src/components/common/index.js @@ -0,0 +1,13 @@ +// ─── Barrel des composants communs ──────────────────────────────────────────── +// Point d'entrée unique : `import { ConfirmModal, InfoModal } from '../components/common';` +// Règle : ne jamais importer ce barrel depuis un fichier de ce dossier +// (imports directs entre composants communs) pour exclure tout cycle. + +export { default as ActionSheetModal } from './ActionSheetModal'; +export { default as ConfirmModal } from './ConfirmModal'; +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as InfoModal } from './InfoModal'; +export { default as NotificationBanner } from './NotificationBanner'; +export { default as QuestToast } from './QuestToast'; +export { default as WeightEntryModal } from './WeightEntryModal'; +export { modalStyles } from './modalStyles'; diff --git a/front/src/components/common/modalStyles.js b/front/src/components/common/modalStyles.js new file mode 100644 index 0000000..473e4fc --- /dev/null +++ b/front/src/components/common/modalStyles.js @@ -0,0 +1,69 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from '../../constants/theme'; + +// ─── modalStyles ────────────────────────────────────────────────────────────── +// Styles partagés par la famille des modales centrées (ConfirmModal, InfoModal). +// Source de vérité unique pour le rendu "carte sombre centrée + badge d'icône +// + CTA plein" — toute évolution visuelle de ces modales se fait ici, une fois. +// +// Les accents (couleur du badge, du CTA, des bordures) restent passés en inline +// par chaque modale car ils dépendent d'un état (destructive → rouge). + +export const modalStyles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.82)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + card: { + width: '100%', + backgroundColor: Colors.bgDeep2, + borderRadius: 22, + borderWidth: 1, + padding: 28, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.65, + shadowRadius: 32, + elevation: 20, + }, + iconWrap: { + width: 60, + height: 60, + borderRadius: 18, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 18, + }, + title: { + color: Colors.textPrimary, + fontSize: 18, + fontWeight: '800', + letterSpacing: -0.3, + marginBottom: 10, + textAlign: 'center', + }, + body: { + color: Colors.textSecondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + marginBottom: 24, + }, + ctaBtn: { + width: '100%', + height: 50, + borderRadius: 13, + justifyContent: 'center', + alignItems: 'center', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.40, + shadowRadius: 12, + elevation: 6, + }, + ctaTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, +}); diff --git a/front/src/components/home/DailyQuestsCard.js b/front/src/components/home/DailyQuestsCard.js index 721d66e..e0476eb 100644 --- a/front/src/components/home/DailyQuestsCard.js +++ b/front/src/components/home/DailyQuestsCard.js @@ -3,7 +3,7 @@ import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { useQuests } from '../../context/QuestContext'; -import { QUEST_XP, BONUS_XP } from '../../services/quest.service'; +import { QUEST_XP, BONUS_XP } from '../../services'; export default function DailyQuestsCard() { const { quests, completedCount, bonusClaimed, loading } = useQuests(); @@ -56,13 +56,13 @@ export default function DailyQuestsCard() { Bonus toutes quêtes : +{BONUS_XP} XP {allDone && ( - + )} @@ -230,5 +230,5 @@ const styles = StyleSheet.create({ fontSize: 10, fontWeight: '600', }, - bonusTextDone: { color: '#FFD700' }, + bonusTextDone: { color: Colors.gold }, }); diff --git a/front/src/components/home/RecoveryRitualsCard.js b/front/src/components/home/RecoveryRitualsCard.js index 82c1d4b..63769a7 100644 --- a/front/src/components/home/RecoveryRitualsCard.js +++ b/front/src/components/home/RecoveryRitualsCard.js @@ -629,7 +629,7 @@ const styles = StyleSheet.create({ }, modalCard: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 26, borderWidth: 1, paddingHorizontal: 24, diff --git a/front/src/components/inventory/ChestOpeningModal.js b/front/src/components/inventory/ChestOpeningModal.js index 5b6a244..c7a8f61 100644 --- a/front/src/components/inventory/ChestOpeningModal.js +++ b/front/src/components/inventory/ChestOpeningModal.js @@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState } from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated, Easing } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; -import { ITEM_CATALOG, RARITY_META } from '../../services/inventory.service'; +import { ITEM_CATALOG, RARITY_META } from '../../services'; import BirthdayConfetti from '../profile/BirthdayConfetti'; // ─── ChestOpeningModal ──────────────────────────────────────────────────────── diff --git a/front/src/components/profile/AchievementShowcase.js b/front/src/components/profile/AchievementShowcase.js index 9a7f6f4..13af518 100644 --- a/front/src/components/profile/AchievementShowcase.js +++ b/front/src/components/profile/AchievementShowcase.js @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { View, Text, StyleSheet, Modal, ScrollView, TouchableOpacity, Pressable, Dimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; -import { RARITY_META } from '../../services/inventory.service'; +import { RARITY_META } from '../../services'; import { FeaturedModal, TrophyIcon } from './TrophySlot'; // ─── AchievementShowcase ────────────────────────────────────────────────────── @@ -78,14 +78,14 @@ const CATEGORY_COLOR = { profile: Colors.rankViolet, collection: Colors.primary, social: '#22D3EE', - heritage: '#FE7439', + heritage: Colors.primary, force: '#DC2626', - exploration: '#22C55E', + exploration: Colors.valid, secret: '#6366F1', corps: '#D1D5DB', regularite: '#10B981', - special: '#FE7439', - ultime: '#FFD700', + special: Colors.primary, + ultime: Colors.gold, }; const CATEGORY_LABELS = { diff --git a/front/src/components/profile/ActivityHeatmap.js b/front/src/components/profile/ActivityHeatmap.js index a934df5..2e6ac92 100644 --- a/front/src/components/profile/ActivityHeatmap.js +++ b/front/src/components/profile/ActivityHeatmap.js @@ -18,7 +18,7 @@ const INTENSITY_COLOR = { 1: '#4D2718', // léger 2: '#85391E', 3: '#C44C24', - 4: '#FE7439', // plein + 4: Colors.primary, // plein }; function monthLabelFor(dateKey) { diff --git a/front/src/components/profile/BirthdatePicker.js b/front/src/components/profile/BirthdatePicker.js index 22e8713..ed596f0 100644 --- a/front/src/components/profile/BirthdatePicker.js +++ b/front/src/components/profile/BirthdatePicker.js @@ -198,7 +198,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(255,255,255,0.09)', diff --git a/front/src/components/profile/BirthdayCelebration.js b/front/src/components/profile/BirthdayCelebration.js index 97dfdcd..b03c30d 100644 --- a/front/src/components/profile/BirthdayCelebration.js +++ b/front/src/components/profile/BirthdayCelebration.js @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAuth } from '../../context/AuthContext'; -import { checkBirthday } from '../../services/reward.service'; +import { checkBirthday } from '../../services'; import BirthdayModal from './BirthdayModal'; const SHOWN_KEY = 'athly:birthday:shown_date:v1'; diff --git a/front/src/components/profile/BirthdayModal.js b/front/src/components/profile/BirthdayModal.js index d326b3b..d8c93ea 100644 --- a/front/src/components/profile/BirthdayModal.js +++ b/front/src/components/profile/BirthdayModal.js @@ -117,7 +117,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(139,92,246,0.35)', diff --git a/front/src/components/profile/BorderPicker.js b/front/src/components/profile/BorderPicker.js index 58fd2cf..63a40ab 100644 --- a/front/src/components/profile/BorderPicker.js +++ b/front/src/components/profile/BorderPicker.js @@ -290,7 +290,7 @@ const styles = StyleSheet.create({ bottom: 0, left: 0, right: 0, - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderTopLeftRadius: 28, borderTopRightRadius: 28, paddingTop: 12, @@ -369,7 +369,7 @@ const styles = StyleSheet.create({ width: 16, height: 16, borderRadius: 8, backgroundColor: Colors.primary, alignItems: 'center', justifyContent: 'center', - borderWidth: 1.5, borderColor: '#13131C', + borderWidth: 1.5, borderColor: Colors.bgDeep2, }, itemName: { fontSize: 9, fontWeight: '700', textAlign: 'center', color: Colors.textPrimary }, itemUnlock: { color: Colors.textMuted, fontSize: 8, fontWeight: '500', marginTop: 1, textAlign: 'center' }, diff --git a/front/src/components/profile/EmberParticles.js b/front/src/components/profile/EmberParticles.js index 6b17ffe..98e8e11 100644 --- a/front/src/components/profile/EmberParticles.js +++ b/front/src/components/profile/EmberParticles.js @@ -1,5 +1,6 @@ import React, { useRef, useEffect } from 'react'; import { View, StyleSheet, Animated } from 'react-native'; +import { Colors } from '../../constants/theme'; // ─── EmberParticles ─────────────────────────────────────────────────────────── // Braises ascendantes pour les rangs Légende (171+) et ATHLY GOD (200). @@ -16,7 +17,7 @@ function randomBetween(min, max) { return min + Math.random() * (max - min); } -export default function EmberParticles({ visible = false, color = '#C084FC' }) { +export default function EmberParticles({ visible = false, color = Colors.legendAccent }) { const particles = useRef( Array.from({ length: PARTICLE_COUNT }, (_, i) => ({ y: new Animated.Value(0), diff --git a/front/src/components/profile/HeroLevelCard.js b/front/src/components/profile/HeroLevelCard.js index afa646d..eaf0e0e 100644 --- a/front/src/components/profile/HeroLevelCard.js +++ b/front/src/components/profile/HeroLevelCard.js @@ -2,7 +2,7 @@ import React, { useMemo, useRef, useEffect } from 'react'; import { View, Text, StyleSheet, Animated } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; -import { xpToLevel, getRank } from '../../services/stats.service'; +import { xpToLevel, getRank } from '../../services'; import AvatarFrame, { getFrameFootprint } from './AvatarFrame'; const AVATAR_SIZE = 90; diff --git a/front/src/components/profile/LevelUpModal.js b/front/src/components/profile/LevelUpModal.js index 148d162..50fde07 100644 --- a/front/src/components/profile/LevelUpModal.js +++ b/front/src/components/profile/LevelUpModal.js @@ -8,16 +8,16 @@ import BirthdayConfetti from './BirthdayConfetti'; // getRankForLevel() (back/utils/levelHelpers.js), indexé par le libellé // backend puisque cette modale reçoit `rank` en toutes lettres. const RANK_COLORS = { - 'ATHLY GOD': '#FFD700', - 'Légende': '#C084FC', - 'Grand Maître': '#A855F7', - 'Maître': '#8B5CF6', - 'Élite': '#6E6AF0', - 'Warrior': '#6E6AF0', + 'ATHLY GOD': Colors.gold, + 'Légende': Colors.legendAccent, + 'Grand Maître': Colors.rankPurple, + 'Maître': Colors.rankViolet, + 'Élite': Colors.secondaryAccent, + 'Warrior': Colors.secondaryAccent, 'Compétiteur': '#3B82F6', - 'Athlète': '#22C55E', + 'Athlète': Colors.valid, 'Initié': '#FBBF24', - 'Novice': '#FE7439', + 'Novice': Colors.primary, }; // ─── LevelUpModal ───────────────────────────────────────────────────────────── @@ -88,7 +88,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, padding: 28, diff --git a/front/src/components/profile/RecordsShowcasePicker.js b/front/src/components/profile/RecordsShowcasePicker.js index 7f706fa..4675283 100644 --- a/front/src/components/profile/RecordsShowcasePicker.js +++ b/front/src/components/profile/RecordsShowcasePicker.js @@ -2,8 +2,8 @@ import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, FlatList, ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; -import { getMyRecords } from '../../services/social.service'; -import { updateRecordsShowcase } from '../../services/profile.service'; +import { getMyRecords } from '../../services'; +import { updateRecordsShowcase } from '../../services'; const MAX_SHOWCASED = 6; @@ -166,7 +166,7 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', }, sheet: { - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderTopLeftRadius: 24, borderTopRightRadius: 24, paddingHorizontal: 20, diff --git a/front/src/components/profile/StreakBadge.js b/front/src/components/profile/StreakBadge.js index ef34ed1..5bd3695 100644 --- a/front/src/components/profile/StreakBadge.js +++ b/front/src/components/profile/StreakBadge.js @@ -6,7 +6,7 @@ import { const SCROLL_MAX_H = Math.round(Dimensions.get('window').height * 0.38); import { Ionicons } from '@expo/vector-icons'; -import { getStreakMultiplier, STREAK_MILESTONES } from '../../services/stats.service'; +import { getStreakMultiplier, STREAK_MILESTONES } from '../../services'; import { Colors } from '../../constants/theme'; // ─── StreakBonusModal — Roadmap des multiplicateurs ─────────────────────────── @@ -37,7 +37,7 @@ function StreakBonusModal({ visible, streak, onClose }) { {/* Header */} - + Régularité & Multiplicateurs @@ -133,7 +133,7 @@ export default function StreakBadge({ streak = 0, compact = false }) { if (streak === 0) return null; - const flameColor = color || '#FE7439'; + const flameColor = color || Colors.primary; const openModal = () => setModalVisible(true); @@ -270,8 +270,8 @@ const ms = StyleSheet.create({ letterSpacing: 1.2, marginBottom: 8, }, currentRow: { flexDirection: 'row', alignItems: 'baseline', marginBottom: 12 }, - currentDays: { color: '#FE7439', fontSize: 36, fontWeight: '900' }, - currentUnit: { color: '#FE7439', fontSize: 16, fontWeight: '600' }, + currentDays: { color: Colors.primary, fontSize: 36, fontWeight: '900' }, + currentUnit: { color: Colors.primary, fontSize: 16, fontWeight: '600' }, multChip: { marginLeft: 10, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, borderWidth: 1, diff --git a/front/src/components/profile/TitlePickerModal.js b/front/src/components/profile/TitlePickerModal.js index 0285a31..d3630a3 100644 --- a/front/src/components/profile/TitlePickerModal.js +++ b/front/src/components/profile/TitlePickerModal.js @@ -3,9 +3,9 @@ import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; -import { getTitles, equipTitle } from '../../services/title.service'; -import { RARITY_META } from '../../services/inventory.service'; -import { haptics } from '../../services/haptics.service'; +import { getTitles, equipTitle } from '../../services'; +import { RARITY_META } from '../../services'; +import { haptics } from '../../services'; // ─── TitleInfoModal ─────────────────────────────────────────────────────────── // Affiche la quête exacte pour un titre encore verrouillé, avec une barre de @@ -188,7 +188,7 @@ const s = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, }, sheet: { - width: '100%', maxHeight: '85%', backgroundColor: '#13131C', + width: '100%', maxHeight: '85%', backgroundColor: Colors.bgDeep2, borderRadius: 24, borderWidth: 1, borderColor: `${Colors.primary}38`, padding: 22, paddingTop: 40, }, @@ -227,7 +227,7 @@ const s = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24, }, infoCard: { - width: '100%', backgroundColor: '#13131C', borderRadius: 22, borderWidth: 1, + width: '100%', backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, padding: 28, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.65, shadowRadius: 32, elevation: 20, }, diff --git a/front/src/components/profile/TrophyGrid.js b/front/src/components/profile/TrophyGrid.js index 6918ae3..f9c929b 100644 --- a/front/src/components/profile/TrophyGrid.js +++ b/front/src/components/profile/TrophyGrid.js @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { View, StyleSheet } from 'react-native'; -import { MAX_FEATURED } from '../../hooks/useFeaturedTrophies'; +import { MAX_FEATURED } from '../../hooks'; import { TrophySlot, EmptySlot, FeaturedModal } from './TrophySlot'; // featuredIds + toggleFeatured proviennent du parent (ProfileScreen via useFeaturedTrophies) diff --git a/front/src/components/social/ActivityFeedModal.js b/front/src/components/social/ActivityFeedModal.js index 21472b6..759c755 100644 --- a/front/src/components/social/ActivityFeedModal.js +++ b/front/src/components/social/ActivityFeedModal.js @@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Modal, TouchableOpacity, ScrollView } from 'rea import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { useAuth } from '../../context/AuthContext'; -import { getActivityFeed, reactToActivityEvent } from '../../services/social.service'; +import { getActivityFeed, reactToActivityEvent } from '../../services'; // ─── ActivityFeedModal ───────────────────────────────────────────────────────── // Flux d'activité « Taquineries & High-Fives » (Section IV) : au lancement de @@ -138,7 +138,7 @@ const styles = StyleSheet.create({ card: { width: '100%', maxHeight: '78%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', diff --git a/front/src/components/social/AddFriendModal.js b/front/src/components/social/AddFriendModal.js index 0abdba0..ee86b7a 100644 --- a/front/src/components/social/AddFriendModal.js +++ b/front/src/components/social/AddFriendModal.js @@ -120,7 +120,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: `${Colors.primary}38`, diff --git a/front/src/components/social/ExercisePickerModal.js b/front/src/components/social/ExercisePickerModal.js index f6c7f0c..8f227b4 100644 --- a/front/src/components/social/ExercisePickerModal.js +++ b/front/src/components/social/ExercisePickerModal.js @@ -121,7 +121,7 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', }, sheet: { - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderTopLeftRadius: 24, borderTopRightRadius: 24, paddingHorizontal: 20, diff --git a/front/src/components/social/FriendPreviewModal.js b/front/src/components/social/FriendPreviewModal.js index 58e16ec..ee5036a 100644 --- a/front/src/components/social/FriendPreviewModal.js +++ b/front/src/components/social/FriendPreviewModal.js @@ -104,7 +104,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: `${Colors.primary}38`, diff --git a/front/src/components/social/FriendshipLevelUpModal.js b/front/src/components/social/FriendshipLevelUpModal.js index 9663bc5..67cd3ef 100644 --- a/front/src/components/social/FriendshipLevelUpModal.js +++ b/front/src/components/social/FriendshipLevelUpModal.js @@ -8,7 +8,7 @@ import BirthdayConfetti from '../profile/BirthdayConfetti'; // même esprit que RANK_COLORS dans LevelUpModal.js, mais indexé par niveau // d'amitié (1 à 5) plutôt que par rang de niveau de joueur. const FRIENDSHIP_LEVEL_COLORS = { - 1: '#9AA0AE', + 1: Colors.textSecondary, 2: '#FF8FA3', 3: '#FF4D6D', 4: '#E11D48', @@ -87,7 +87,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, padding: 28, diff --git a/front/src/components/stats/WeightReminderCheck.js b/front/src/components/stats/WeightReminderCheck.js index 0b1cae4..61b8f5d 100644 --- a/front/src/components/stats/WeightReminderCheck.js +++ b/front/src/components/stats/WeightReminderCheck.js @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAuth } from '../../context/AuthContext'; -import { getWeightHistory } from '../../services/weight.service'; +import { getWeightHistory } from '../../services'; import WeightReminderModal from './WeightReminderModal'; import WeightEntryModal from '../common/WeightEntryModal'; diff --git a/front/src/components/stats/WeightReminderModal.js b/front/src/components/stats/WeightReminderModal.js index 407f2a0..7292cb2 100644 --- a/front/src/components/stats/WeightReminderModal.js +++ b/front/src/components/stats/WeightReminderModal.js @@ -54,7 +54,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: `${Colors.primary}38`, diff --git a/front/src/components/stats/XPProgressBar.js b/front/src/components/stats/XPProgressBar.js index 0e465df..924e7cb 100644 --- a/front/src/components/stats/XPProgressBar.js +++ b/front/src/components/stats/XPProgressBar.js @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { Colors } from '../../constants/theme'; -import { xpToLevel } from '../../services/stats.service'; +import { xpToLevel } from '../../services'; // Bar de progression niveau / XP. Reçoit le total XP cumulé. // Visualise : niveau actuel + barre + xp restant pour next level. diff --git a/front/src/components/tutorial/TutorialOverlay.js b/front/src/components/tutorial/TutorialOverlay.js index b34cf25..6dbf80d 100644 --- a/front/src/components/tutorial/TutorialOverlay.js +++ b/front/src/components/tutorial/TutorialOverlay.js @@ -6,7 +6,7 @@ import { import { Ionicons } from '@expo/vector-icons'; import { useTutorial } from '../../context/TutorialContext'; import { CHAPTER_IDS } from '../../data/tutorialChapters'; -import { haptics } from '../../services/haptics.service'; +import { haptics } from '../../services'; import { Colors } from '../../constants/theme'; // Padding visuel autour du spotlight (léger, ne perturbe pas les coordonnées) @@ -262,7 +262,7 @@ export default function TutorialOverlay({ navigation }) { Passer diff --git a/front/src/components/typography/SectionTitle.js b/front/src/components/typography/SectionTitle.js deleted file mode 100644 index 782dcec..0000000 --- a/front/src/components/typography/SectionTitle.js +++ /dev/null @@ -1,21 +0,0 @@ -// ============================================================================================ -// Composant SectionTitle -// Affiche un titre de section avec un style uniforme -// ============================================================================================ - -import React from "react"; -import { Text, StyleSheet } from "react-native"; -import { Colors } from "../../constants/theme"; - -export default function SectionTitle({ title }) { - return {title}; -} - -const styles = StyleSheet.create({ - section: { - color: Colors.textPrimary, - fontSize: 18, - marginVertical: 16, - fontWeight: "bold", - }, -}); \ No newline at end of file diff --git a/front/src/components/ui/AppToast.js b/front/src/components/ui/AppToast.js index ea8d914..fbeaeb7 100644 --- a/front/src/components/ui/AppToast.js +++ b/front/src/components/ui/AppToast.js @@ -4,13 +4,14 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; // ─── Config par type ───────────────────────────────────────────────────────── const TYPE_CONFIG = { - success: { icon: 'checkmark-circle', color: '#22C55E' }, - error: { icon: 'close-circle', color: '#FF4D4D' }, - warning: { icon: 'warning', color: '#F59E0B' }, + success: { icon: 'checkmark-circle', color: Colors.valid }, + error: { icon: 'close-circle', color: Colors.error }, + warning: { icon: 'warning', color: Colors.warningAmber }, info: { icon: 'information-circle', color: '#4A9EFF' }, }; @@ -132,7 +133,7 @@ const styles = StyleSheet.create({ }, message: { flex: 1, - color: '#FFFFFF', + color: Colors.textPrimary, fontSize: 14, fontWeight: '600', lineHeight: 20, diff --git a/front/src/components/web/DesktopInstallPage.js b/front/src/components/web/DesktopInstallPage.js index fe227b0..eb7e80a 100644 --- a/front/src/components/web/DesktopInstallPage.js +++ b/front/src/components/web/DesktopInstallPage.js @@ -1,6 +1,7 @@ import React from 'react'; import { View, Text, Image, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/theme'; const LOGO = require('../../../assets/logo-orange.png'); @@ -60,7 +61,7 @@ export default function DesktopInstallPage() { icon="logo-chrome" title="Chrome" platform="Android" - accent="#FE7439" + accent={Colors.primary} steps={[ 'Ouvrez ce lien dans Chrome sur votre Android', 'Appuyez sur ⋮ (3 points) en haut à droite', @@ -71,7 +72,7 @@ export default function DesktopInstallPage() { icon="logo-apple" title="Safari" platform="iPhone · iPad" - accent="#6E6AF0" + accent={Colors.secondaryAccent} steps={[ 'Ouvrez ce lien dans Safari sur votre iPhone', 'Appuyez sur l\'icône Partager (□↑) en bas', @@ -105,7 +106,7 @@ const s = StyleSheet.create({ card: { width: '100%', maxWidth: 700, - backgroundColor: '#080910', + backgroundColor: Colors.bgAbyss, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(110,106,240,0.18)', @@ -126,7 +127,7 @@ const s = StyleSheet.create({ headline: { fontSize: 32, fontWeight: '800', - color: '#FFFFFF', + color: Colors.textPrimary, textAlign: 'center', letterSpacing: -0.8, marginBottom: 12, @@ -159,7 +160,7 @@ const s = StyleSheet.create({ }, urlText: { fontSize: 15, - color: '#6E6AF0', + color: Colors.secondaryAccent, fontWeight: '600', letterSpacing: 0.2, }, diff --git a/front/src/components/workouts/AddExerciseSheet.js b/front/src/components/workouts/AddExerciseSheet.js index 1c53dd3..d7ae4f8 100644 --- a/front/src/components/workouts/AddExerciseSheet.js +++ b/front/src/components/workouts/AddExerciseSheet.js @@ -203,7 +203,7 @@ const styles = StyleSheet.create({ width: 44, height: 44, borderRadius: 10, - backgroundColor: '#0e0e12', + backgroundColor: Colors.cardInner, alignItems: 'center', justifyContent: 'center', marginRight: 12, diff --git a/front/src/components/workouts/ExerciseHeader.js b/front/src/components/workouts/ExerciseHeader.js index bec7f83..295dad7 100644 --- a/front/src/components/workouts/ExerciseHeader.js +++ b/front/src/components/workouts/ExerciseHeader.js @@ -83,7 +83,7 @@ const styles = StyleSheet.create({ paddingTop: 12, paddingBottom: 22, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#1f1f27', + borderBottomColor: Colors.borderSubtle, }, topRow: { flexDirection: 'row', diff --git a/front/src/components/workouts/LobbyInviteCheck.js b/front/src/components/workouts/LobbyInviteCheck.js index 12550c2..0c05b33 100644 --- a/front/src/components/workouts/LobbyInviteCheck.js +++ b/front/src/components/workouts/LobbyInviteCheck.js @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; import { useAuth } from '../../context/AuthContext'; -import { joinLobby } from '../../services/lobby.service'; +import { joinLobby } from '../../services'; import { navigate } from '../../navigation/navigationRef'; import LobbyInviteModal from './LobbyInviteModal'; diff --git a/front/src/components/workouts/LobbyInviteModal.js b/front/src/components/workouts/LobbyInviteModal.js index 11b0bed..07ddd41 100644 --- a/front/src/components/workouts/LobbyInviteModal.js +++ b/front/src/components/workouts/LobbyInviteModal.js @@ -52,7 +52,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: `${Colors.primary}38`, diff --git a/front/src/components/workouts/LobbyWaitingOverlay.js b/front/src/components/workouts/LobbyWaitingOverlay.js index 4ccc661..54be323 100644 --- a/front/src/components/workouts/LobbyWaitingOverlay.js +++ b/front/src/components/workouts/LobbyWaitingOverlay.js @@ -62,7 +62,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: `${Colors.primary}38`, diff --git a/front/src/components/workouts/MultiLobbyModal.js b/front/src/components/workouts/MultiLobbyModal.js index d597d8f..625640d 100644 --- a/front/src/components/workouts/MultiLobbyModal.js +++ b/front/src/components/workouts/MultiLobbyModal.js @@ -2,8 +2,8 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity, ActivityIndicator, FlatList } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; -import { createLobby, getLobby, joinLobby, inviteToLobby, readyLobby, unreadyLobby } from '../../services/lobby.service'; -import { getFriendsList } from '../../services/social.service'; +import { createLobby, getLobby, joinLobby, inviteToLobby, readyLobby, unreadyLobby } from '../../services'; +import { getFriendsList } from '../../services'; import { useUser } from '../../context/UserContext'; const POLL_MS = 3000; @@ -328,7 +328,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: `${Colors.primary}38`, @@ -361,7 +361,7 @@ const styles = StyleSheet.create({ statusDot: { position: 'absolute', bottom: -4, right: -4, width: 16, height: 16, borderRadius: 8, - borderWidth: 1.5, borderColor: '#13131C', + borderWidth: 1.5, borderColor: Colors.bgDeep2, justifyContent: 'center', alignItems: 'center', }, memberName: { color: Colors.textMuted, fontSize: 10.5, fontWeight: '600', marginTop: 5, textAlign: 'center' }, @@ -413,7 +413,7 @@ const styles = StyleSheet.create({ // ── BonusTableModal ────────────────────────────────────────────────────── tableCard: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: `${Colors.primary}38`, diff --git a/front/src/components/workouts/MultiLootModal.js b/front/src/components/workouts/MultiLootModal.js index 651f6e3..88528cf 100644 --- a/front/src/components/workouts/MultiLootModal.js +++ b/front/src/components/workouts/MultiLootModal.js @@ -56,7 +56,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(255,215,0,0.4)', diff --git a/front/src/components/workouts/MuscleHierarchyPicker.js b/front/src/components/workouts/MuscleHierarchyPicker.js index 8ad90a7..eca1daf 100644 --- a/front/src/components/workouts/MuscleHierarchyPicker.js +++ b/front/src/components/workouts/MuscleHierarchyPicker.js @@ -168,7 +168,7 @@ const styles = StyleSheet.create({ marginBottom: 10, overflow: 'hidden', borderWidth: 1, - borderColor: '#1f1f27', + borderColor: Colors.borderSubtle, }, groupCardActive: { borderColor: 'rgba(254, 116, 57, 0.5)', @@ -183,7 +183,7 @@ const styles = StyleSheet.create({ width: 42, height: 42, borderRadius: 10, - backgroundColor: '#0e0e12', + backgroundColor: Colors.cardInner, alignItems: 'center', justifyContent: 'center', marginRight: 12, diff --git a/front/src/components/workouts/SetTable.js b/front/src/components/workouts/SetTable.js index 9967e72..a38c5d9 100644 --- a/front/src/components/workouts/SetTable.js +++ b/front/src/components/workouts/SetTable.js @@ -125,7 +125,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, headerText: { - color: '#8B95A3', + color: Colors.textDim, fontSize: 11, fontWeight: '700', letterSpacing: 1, diff --git a/front/src/components/workouts/ShortSessionWarningModal.js b/front/src/components/workouts/ShortSessionWarningModal.js index 16bb23d..e4a2fd1 100644 --- a/front/src/components/workouts/ShortSessionWarningModal.js +++ b/front/src/components/workouts/ShortSessionWarningModal.js @@ -80,7 +80,7 @@ const styles = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(255,77,77,0.28)', @@ -88,7 +88,7 @@ const styles = StyleSheet.create({ paddingTop: 32, paddingBottom: 28, alignItems: 'center', - shadowColor: '#FF4D4D', + shadowColor: Colors.error, shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.25, shadowRadius: 28, diff --git a/front/src/components/workouts/WorkoutHeader.js b/front/src/components/workouts/WorkoutHeader.js deleted file mode 100644 index 41ce9b0..0000000 --- a/front/src/components/workouts/WorkoutHeader.js +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { Colors } from '../../constants/theme'; - -// Logo ATHLY (mark + wordmark) pour rester pixel-perfect avec la maquette. -function AthlyLogo() { - return ( - - - ATHLY - - ); -} - -export default function WorkoutHeader({ title = 'Séance', subtitle = null }) { - return ( - - - {title} - {subtitle ? {subtitle} : null} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 20, - paddingTop: 40, - paddingBottom: 14, - backgroundColor: Colors.background, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#1f1f27', - }, - titleBlock: { - flex: 1, - paddingRight: 12, - }, - title: { - color: Colors.textPrimary, - fontSize: 26, - fontWeight: '700', - letterSpacing: 0.2, - }, - subtitle: { - color: Colors.textSecondary, - fontSize: 13, - marginTop: 4, - }, - logoWrap: { - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 6, - }, - logoMark: { - color: Colors.primary, - fontSize: 26, - lineHeight: 28, - fontWeight: '900', - marginBottom: 2, - }, - logoText: { - color: Colors.primary, - fontSize: 11, - fontWeight: '900', - letterSpacing: 1.5, - }, -}); diff --git a/front/src/components/workouts/WorkoutRecapModal.js b/front/src/components/workouts/WorkoutRecapModal.js index 43b2073..870a09a 100644 --- a/front/src/components/workouts/WorkoutRecapModal.js +++ b/front/src/components/workouts/WorkoutRecapModal.js @@ -16,8 +16,8 @@ const SCREEN_H = Dimensions.get('window').height; import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; import { Colors } from '../../constants/theme'; -import { xpToLevel, getRank } from '../../services/stats.service'; -import { QUEST_XP, BONUS_XP } from '../../services/quest.service'; +import { xpToLevel, getRank } from '../../services'; +import { QUEST_XP, BONUS_XP } from '../../services'; const LOGO_VIOLET = require('../../../assets/logo-violet.png'); @@ -464,13 +464,13 @@ export default function WorkoutRecapModal({ {streakBonusXP > 0 && ( Bonus Régularité ×{xpMultiplier} - +{streakBonusXP.toLocaleString('fr-FR')} XP + +{streakBonusXP.toLocaleString('fr-FR')} XP )} {questXPEarned > 0 && ( Bonus Quêtes - +{questXPEarned.toLocaleString('fr-FR')} XP + +{questXPEarned.toLocaleString('fr-FR')} XP )} @@ -557,9 +557,9 @@ export default function WorkoutRecapModal({ ))} {bonusUnlocked ? ( - - Bonus toutes quêtes - +{BONUS_XP} XP + + Bonus toutes quêtes + +{BONUS_XP} XP ) : null} diff --git a/front/src/constants/theme.js b/front/src/constants/theme.js index 2bb4801..4142b4a 100644 --- a/front/src/constants/theme.js +++ b/front/src/constants/theme.js @@ -51,9 +51,10 @@ export const Colors = { uniqueBloodGlow: 'rgba(163,0,0,0.65)', // ── Feedback ─────────────────────────────────────────────────────────────── - valid: '#22C55E', // sets / exercices validés (vert chaud) - success: '#44FF88', // XP / gains (vert néon) - error: '#FF4D4D', + valid: '#22C55E', // sets / exercices validés (vert chaud) + success: '#44FF88', // XP / gains (vert néon) + error: '#FF4D4D', + destructive: '#EF4444', // actions destructrices (Supprimer…), accent rouge des modales // ── Utilitaires ──────────────────────────────────────────────────────────── chevron: '#888888', diff --git a/front/src/context/CustomExercisesContext.js b/front/src/context/CustomExercisesContext.js index 48cd449..8bfcd38 100644 --- a/front/src/context/CustomExercisesContext.js +++ b/front/src/context/CustomExercisesContext.js @@ -4,7 +4,7 @@ import { addCustomExercise, updateCustomExercise, removeCustomExercise, -} from '../services/customExercises.service'; +} from '../services'; // Context global pour les exercices personnalisés. // - Charge la liste depuis AsyncStorage au montage diff --git a/front/src/context/QuestContext.js b/front/src/context/QuestContext.js index d15a0f6..e60ea5d 100644 --- a/front/src/context/QuestContext.js +++ b/front/src/context/QuestContext.js @@ -12,7 +12,7 @@ import { getTemplateById, QUEST_XP, BONUS_XP, -} from '../services/quest.service'; +} from '../services'; import { useWorkoutLogs } from './WorkoutLogsContext'; const QuestContext = createContext(null); diff --git a/front/src/context/SavedWorkoutsContext.js b/front/src/context/SavedWorkoutsContext.js index da59366..4c69a56 100644 --- a/front/src/context/SavedWorkoutsContext.js +++ b/front/src/context/SavedWorkoutsContext.js @@ -4,7 +4,7 @@ import { saveWorkout, updateSavedWorkout, removeSavedWorkout, -} from '../services/savedWorkouts.service'; +} from '../services'; // Context global pour les séances sauvegardées (favoris / réutilisables). // Chargement initial depuis AsyncStorage, mise à jour optimiste après chaque action. diff --git a/front/src/context/TutorialContext.js b/front/src/context/TutorialContext.js index 87bd0b8..a549e40 100644 --- a/front/src/context/TutorialContext.js +++ b/front/src/context/TutorialContext.js @@ -1,7 +1,7 @@ import React, { createContext, useState, useContext, useCallback, useEffect, useRef } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { TUTORIAL_CHAPTERS, CHAPTER_MAP, CHAPTER_IDS } from '../data/tutorialChapters'; -import { completeOnboarding } from '../services/onboarding.service'; +import { CHAPTER_MAP, CHAPTER_IDS } from '../data/tutorialChapters'; +import { completeOnboarding } from '../services'; const DONE_KEY = 'athly:tutorial:completed:v1'; const PENDING_KEY = 'athly:tutorial:pendingChapter:v1'; diff --git a/front/src/context/WorkoutInProgressContext.js b/front/src/context/WorkoutInProgressContext.js index 1853d4e..8807bf7 100644 --- a/front/src/context/WorkoutInProgressContext.js +++ b/front/src/context/WorkoutInProgressContext.js @@ -1,8 +1,8 @@ import React, { createContext, useContext, useCallback, useMemo } from 'react'; -import useWorkoutState from '../hooks/useWorkoutState'; +import { useWorkoutState } from '../hooks'; import { useWorkoutLogs } from './WorkoutLogsContext'; import { useQuests } from './QuestContext'; -import { buildLogFromWorkout, findNewPRsInLog } from '../services/stats.service'; +import { buildLogFromWorkout, findNewPRsInLog } from '../services'; // Context global pour la séance en cours. // Centralise useWorkoutState : tous les écrans consomment le même état + actions diff --git a/front/src/context/WorkoutLogsContext.js b/front/src/context/WorkoutLogsContext.js index 8b0f178..7945e1e 100644 --- a/front/src/context/WorkoutLogsContext.js +++ b/front/src/context/WorkoutLogsContext.js @@ -1,7 +1,7 @@ import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { AppState } from 'react-native'; -import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog, addBonusXpLog } from '../services/stats.service'; -import { syncXp, retryPendingXpSync } from '../services/xpSync.service'; +import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog, addBonusXpLog } from '../services'; +import { syncXp, retryPendingXpSync } from '../services'; // Context global pour l'historique des séances finalisées (logs). // Source de vérité unique pour StatsScreen, ExerciseStatsScreen, ProfileScreen. diff --git a/front/src/hooks/index.js b/front/src/hooks/index.js new file mode 100644 index 0000000..ab25cbd --- /dev/null +++ b/front/src/hooks/index.js @@ -0,0 +1,11 @@ +// ─── Barrel des hooks ───────────────────────────────────────────────────────── +// Point d'entrée unique : `import { useAvatarFrame, useEffortTimer } from '../hooks';` +// Les hooks à export default sont ré-exportés sous leur nom canonique. + +export { useAvatarFrame } from './useAvatarFrame'; +export { useDevSettings } from './useDevSettings'; +export { default as useEffortTimer, formatDuration } from './useEffortTimer'; +export { default as useExerciseSorting, isFiltering } from './useExerciseSorting'; +export { useFeaturedTrophies, MAX_FEATURED } from './useFeaturedTrophies'; +export { useGoogleAuth } from './useGoogleAuth'; +export { default as useWorkoutState } from './useWorkoutState'; diff --git a/front/src/hooks/useWorkoutState.js b/front/src/hooks/useWorkoutState.js index 0afd2e0..aeb5cbe 100644 --- a/front/src/hooks/useWorkoutState.js +++ b/front/src/hooks/useWorkoutState.js @@ -1,5 +1,5 @@ import { useReducer, useEffect, useRef, useCallback, useMemo } from 'react'; -import API from '../api/api'; +import { createWorkoutDraft, updateWorkoutDraft, finalizeWorkout } from '../services/workouts.service'; // --------------------------------------------------------------------------- // useWorkoutState @@ -229,27 +229,22 @@ export default function useWorkoutState(initial = {}) { isSaving.current = true; try { if (!state.id) { - const res = await API.post('/workouts/draft', { + const workout = await createWorkoutDraft({ name: state.name, exercises: state.exercises, notes: state.notes, - status: 'draft', }); - const data = res && res.data ? res.data : res; - const workout = data && data.workout ? data.workout : null; if (workout) { dispatch({ type: ACTIONS.SET_WORKOUT, payload: { id: workout._id } }); return workout; } return null; } - const res = await API.patch(`/workouts/${state.id}/draft`, { + return await updateWorkoutDraft(state.id, { exercises: state.exercises, notes: state.notes, durationSeconds: state.durationSeconds, }); - const data = res && res.data ? res.data : res; - return data && data.workout ? data.workout : data; } catch (error) { // Pas de Alert ici pour éviter le spam — on le centralise dans finalize/handler explicite. return null; @@ -267,13 +262,12 @@ export default function useWorkoutState(initial = {}) { const saved = await saveDraft(); const workoutId = state.id || (saved && (saved._id || saved.id)); if (!workoutId) throw new Error('no_id'); - const res = await API.post(`/workouts/${workoutId}/finalize`, { + const data = await finalizeWorkout(workoutId, { exercises: state.exercises, notes: state.notes, durationSeconds: state.durationSeconds, ...options, }); - const data = res && res.data ? res.data : res; if (data && data.stats) { dispatch({ type: ACTIONS.MARK_FINISHED }); return data.stats; diff --git a/front/src/navigation/index.js b/front/src/navigation/index.js index c1d0963..de3746f 100644 --- a/front/src/navigation/index.js +++ b/front/src/navigation/index.js @@ -19,8 +19,8 @@ import { QuestProvider } from '../context/QuestContext'; import { UserProvider } from '../context/UserContext'; import { TutorialProvider } from '../context/TutorialContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { setupNotificationChannels, ensureDailyRemindersScheduled, getExpoPushToken } from '../services/notificationService'; -import { registerPushToken } from '../services/profile.service'; +import { setupNotificationChannels, ensureDailyRemindersScheduled, getExpoPushToken } from '../services'; +import { registerPushToken } from '../services'; import BirthdayCelebration from '../components/profile/BirthdayCelebration'; import LevelUpCelebration from '../components/profile/LevelUpCelebration'; import ActivityFeedModal from '../components/social/ActivityFeedModal'; diff --git a/front/src/screens/Auth/AuthScreen.js b/front/src/screens/Auth/AuthScreen.js deleted file mode 100644 index e4a3609..0000000 --- a/front/src/screens/Auth/AuthScreen.js +++ /dev/null @@ -1,396 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { - View, Text, StyleSheet, TouchableOpacity, Image, - KeyboardAvoidingView, Platform, ActivityIndicator, - Dimensions, Animated, Easing, ScrollView, StatusBar, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; - -import { Colors } from '../../constants/theme'; -import AuthInput from '../../components/inputs/AuthInput'; -import { login, register } from '../../services/auth.service'; -import { useAuth } from '../../context/AuthContext'; - -// Imports statiques des assets (chemin : src/screens/Auth/ → ../../../assets/) -const LOGO_ORANGE = require('../../../assets/logo-orange.png'); -const LOGO_ICON_ORANGE = require('../../../assets/minimalist-logo-orange.png'); - -const { width: _rawW } = Dimensions.get('window'); -const W = Platform.OS === 'web' ? Math.min(430, _rawW) : _rawW; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -function FadeLoader() { - const opacity = useRef(new Animated.Value(0)).current; - useEffect(() => { - Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }).start(); - }, []); - return ( - - - - ); -} - -export default function AuthScreen() { - const { signIn } = useAuth(); - const slideAnim = useRef(new Animated.Value(0)).current; - - // ── Login state ────────────────────────────────────────── - const [loginEmail, setLoginEmail] = useState(''); - const [loginPassword, setLoginPassword] = useState(''); - const [loginLoading, setLoginLoading] = useState(false); - const [loginShowPwd, setLoginShowPwd] = useState(false); - const [loginEmailErr, setLoginEmailErr] = useState(''); - const [loginGlobalErr, setLoginGlobalErr] = useState(''); - - // ── Register state ─────────────────────────────────────── - const [regName, setRegName] = useState(''); - const [regEmail, setRegEmail] = useState(''); - const [regPassword, setRegPassword] = useState(''); - const [regLoading, setRegLoading] = useState(false); - const [regShowPwd, setRegShowPwd] = useState(false); - const [regNameErr, setRegNameErr] = useState(''); - const [regEmailErr, setRegEmailErr] = useState(''); - const [regPwdErr, setRegPwdErr] = useState(''); - const [regGlobalErr, setRegGlobalErr] = useState(''); - - // ── Slide ──────────────────────────────────────────────── - const slideTo = (panel) => { - Animated.timing(slideAnim, { - toValue: panel === 'login' ? 0 : -W, - duration: 400, - easing: Easing.out(Easing.exp), - useNativeDriver: true, - }).start(); - }; - - // ── Validators ─────────────────────────────────────────── - const validateLoginEmail = (val = loginEmail) => { - if (!val) { setLoginEmailErr('Email requis'); return false; } - if (!EMAIL_RE.test(val)) { setLoginEmailErr('Email invalide'); return false; } - setLoginEmailErr(''); return true; - }; - - const validateRegEmail = (val = regEmail) => { - if (!val) { setRegEmailErr('Email requis'); return false; } - if (!EMAIL_RE.test(val)) { setRegEmailErr('Email invalide'); return false; } - setRegEmailErr(''); return true; - }; - - // ── Handlers ───────────────────────────────────────────── - const handleLogin = async () => { - if (!validateLoginEmail() || !loginPassword) return; - try { - setLoginLoading(true); - setLoginGlobalErr(''); - const res = await login(loginEmail, loginPassword); - if (res?.token) await signIn(res.token); - else throw new Error(); - } catch { - setLoginGlobalErr('Email ou mot de passe incorrect.'); - } finally { - setLoginLoading(false); - } - }; - - const handleRegister = async () => { - let ok = true; - if (!regName) { setRegNameErr('Nom requis'); ok = false; } else setRegNameErr(''); - if (!validateRegEmail()) ok = false; - if (regPassword.length < 6) { setRegPwdErr('6 caractères minimum'); ok = false; } else setRegPwdErr(''); - if (!ok) return; - try { - setRegLoading(true); - setRegGlobalErr(''); - const res = await register({ name: regName, email: regEmail, password: regPassword }); - if (res?.token) await signIn(res.token); - else slideTo('login'); - } catch { - setRegGlobalErr("Erreur lors de l'inscription. Email déjà utilisé ?"); - } finally { - setRegLoading(false); - } - }; - - return ( - - - - - - - - {/* ══════════════════════════════════════════════════ - PANEL LOGIN - ══════════════════════════════════════════════════ */} - - - - - Bienvenue - Connectez-vous pour continuer - - - { setLoginEmail(v); if (loginEmailErr) validateLoginEmail(v); }} - onBlur={() => validateLoginEmail()} - error={loginEmailErr} - keyboardType="email-address" - autoCapitalize="none" - autoCorrect={false} - /> - - - {loginGlobalErr ? {loginGlobalErr} : null} - - - {loginLoading ? : Se connecter} - - - - Pas encore de compte ? - slideTo('register')}> - S'inscrire - - - - - {/* ══════════════════════════════════════════════════ - PANEL REGISTER - ══════════════════════════════════════════════════ */} - - slideTo('login')}> - - - - - - - Créer un compte - Rejoignez la communauté Athly - - - { setRegName(v); if (regNameErr) setRegNameErr(''); }} - error={regNameErr} - /> - { setRegEmail(v); if (regEmailErr) validateRegEmail(v); }} - onBlur={() => validateRegEmail()} - error={regEmailErr} - keyboardType="email-address" - autoCapitalize="none" - autoCorrect={false} - /> - { - setRegPassword(v); - if (regPwdErr) setRegPwdErr(v.length >= 6 ? '' : '6 caractères minimum'); - }} - isPassword - secureTextEntry={!regShowPwd} - showPassword={regShowPwd} - setShowPassword={setRegShowPwd} - error={regPwdErr} - /> - - {regGlobalErr ? {regGlobalErr} : null} - - - {regLoading ? : Créer mon compte} - - - - Déjà inscrit ? - slideTo('login')}> - Se connecter - - - - - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: Colors.backgroundDeep, - }, - kav: { - flex: 1, - backgroundColor: Colors.backgroundDeep, - }, - clipper: { - flex: 1, - overflow: 'hidden', - backgroundColor: Colors.backgroundDeep, - }, - slider: { - flex: 1, - flexDirection: 'row', - width: W * 2, - }, - panel: { - width: W, - backgroundColor: Colors.backgroundDeep, - }, - panelContent: { - paddingHorizontal: 28, - paddingTop: 20, - paddingBottom: 56, - }, - - // ── Logo Login (image brute, dimensions explicites) ────── - logoImg: { - width: 180, - height: 120, - alignSelf: 'center', - marginBottom: 30, - marginTop: 4, - }, - // ── Logo Register (icône discrète) ─────────────────────── - logoSmall: { - width: 26, - height: 26, - alignSelf: 'flex-start', - marginBottom: 20, - opacity: 0.6, - }, - - // ── Typographie ────────────────────────────────────────── - titleBlock: { - marginBottom: 32, - }, - brand: { - color: Colors.textPrimary, - fontSize: 30, - fontWeight: '700', - letterSpacing: -0.5, - marginBottom: 6, - }, - tagline: { - color: Colors.textMuted, - fontSize: 14, - letterSpacing: 0.2, - }, - - // ── Erreur globale ─────────────────────────────────────── - globalError: { - color: Colors.error, - fontSize: 13, - textAlign: 'center', - marginTop: 8, - marginBottom: 4, - }, - - // ── Bouton principal ───────────────────────────────────── - primaryBtn: { - backgroundColor: Colors.primary, - height: 56, - borderRadius: 14, - justifyContent: 'center', - alignItems: 'center', - marginTop: 24, - shadowColor: Colors.primary, - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.4, - shadowRadius: 16, - elevation: 8, - }, - btnText: { - color: '#fff', - fontSize: 16, - fontWeight: '700', - letterSpacing: 0.4, - }, - - // ── Lien de bascule ────────────────────────────────────── - switchRow: { - flexDirection: 'row', - justifyContent: 'center', - marginTop: 36, - }, - switchLabel: { - color: Colors.textMuted, - fontSize: 14, - }, - linkBold: { - color: Colors.primary, - fontWeight: '700', - fontSize: 14, - }, - - // ── Retour ─────────────────────────────────────────────── - backBtn: { - width: 36, - height: 36, - justifyContent: 'center', - marginBottom: 8, - }, -}); diff --git a/front/src/screens/Auth/EmailVerificationScreen.js b/front/src/screens/Auth/EmailVerificationScreen.js index 24b85cf..8c08329 100644 --- a/front/src/screens/Auth/EmailVerificationScreen.js +++ b/front/src/screens/Auth/EmailVerificationScreen.js @@ -8,8 +8,8 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; -import NotificationBanner from '../../components/common/NotificationBanner'; -import { verifyEmail, resendVerificationEmail } from '../../services/auth.service'; +import { NotificationBanner } from '../../components/common'; +import { verifyEmail, resendVerificationEmail } from '../../services'; import { useAuth } from '../../context/AuthContext'; const CODE_LENGTH = 6; diff --git a/front/src/screens/Auth/ForgotPasswordScreen.js b/front/src/screens/Auth/ForgotPasswordScreen.js index 3057934..bd3d90a 100644 --- a/front/src/screens/Auth/ForgotPasswordScreen.js +++ b/front/src/screens/Auth/ForgotPasswordScreen.js @@ -9,8 +9,8 @@ import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import AuthInput from '../../components/inputs/AuthInput'; -import NotificationBanner from '../../components/common/NotificationBanner'; -import { forgotPassword, resetPassword } from '../../services/auth.service'; +import { NotificationBanner } from '../../components/common'; +import { forgotPassword, resetPassword } from '../../services'; import { useToast } from '../../context/ToastContext'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -33,7 +33,7 @@ function getStrength(pwd) { function StrengthBar({ password }) { const score = getStrength(password); if (!password) return null; - const color = score === 3 ? '#44FF88' : score === 2 ? '#FFA500' : '#FF4D4D'; + const color = score === 3 ? Colors.success : score === 2 ? '#FFA500' : Colors.error; const label = score === 3 ? 'Fort' : score === 2 ? 'Moyen' : 'Faible'; return ( @@ -134,7 +134,7 @@ function WeakPasswordModal({ visible, onImprove, onSave, loading }) { - + Mot de passe simple @@ -182,7 +182,7 @@ const wm = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(245,158,11,0.25)', diff --git a/front/src/screens/Auth/LoginScreen.js b/front/src/screens/Auth/LoginScreen.js index f535d91..e7b9b9f 100644 --- a/front/src/screens/Auth/LoginScreen.js +++ b/front/src/screens/Auth/LoginScreen.js @@ -9,10 +9,10 @@ import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import AuthInput from '../../components/inputs/AuthInput'; -import NotificationBanner from '../../components/common/NotificationBanner'; -import { login, googleLogin } from '../../services/auth.service'; +import { NotificationBanner } from '../../components/common'; +import { login, googleLogin } from '../../services'; import { useAuth } from '../../context/AuthContext'; -import { useGoogleAuth } from '../../hooks/useGoogleAuth'; +import { useGoogleAuth } from '../../hooks'; const LOGO_ORANGE = require('../../../assets/logo-orange.png'); const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; diff --git a/front/src/screens/Auth/RegisterScreen.js b/front/src/screens/Auth/RegisterScreen.js index 04aed33..d9170a7 100644 --- a/front/src/screens/Auth/RegisterScreen.js +++ b/front/src/screens/Auth/RegisterScreen.js @@ -9,9 +9,9 @@ import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import AuthInput from '../../components/inputs/AuthInput'; -import NotificationBanner from '../../components/common/NotificationBanner'; -import { register } from '../../services/auth.service'; -import { haptics } from '../../services/haptics.service'; +import { NotificationBanner } from '../../components/common'; +import { register } from '../../services'; +import { haptics } from '../../services'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const HAS_UPPER = /[A-Z]/; @@ -32,7 +32,7 @@ function getStrength(pwd) { function StrengthBar({ password }) { const score = getStrength(password); if (!password) return null; - const color = score === 3 ? '#44FF88' : score === 2 ? '#FFA500' : '#FF4D4D'; + const color = score === 3 ? Colors.success : score === 2 ? '#FFA500' : Colors.error; const label = score === 3 ? 'Fort' : score === 2 ? 'Moyen' : 'Faible'; return ( @@ -76,7 +76,7 @@ function WeakPasswordModal({ visible, onImprove, onCreate, loading }) { {/* Icône */} - + Mot de passe simple @@ -124,7 +124,7 @@ const wm = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(245,158,11,0.25)', @@ -219,7 +219,7 @@ function PseudoRejectedModal({ visible, onClose }) { - + Pseudo non autorisé @@ -251,7 +251,7 @@ const pm = StyleSheet.create({ }, card: { width: '100%', - backgroundColor: '#13131C', + backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(239,68,68,0.35)', @@ -271,11 +271,11 @@ const pm = StyleSheet.create({ }, title: { color: Colors.textPrimary, fontSize: 19, fontWeight: '800', letterSpacing: -0.3, marginBottom: 12, textAlign: 'center' }, body: { color: Colors.textSecondary, fontSize: 14, lineHeight: 21, textAlign: 'center', marginBottom: 14 }, - warning: { color: '#EF4444', fontSize: 12.5, fontWeight: '700', lineHeight: 18, textAlign: 'center', marginBottom: 24 }, + warning: { color: Colors.destructive, fontSize: 12.5, fontWeight: '700', lineHeight: 18, textAlign: 'center', marginBottom: 24 }, closeBtn: { width: '100%', height: 50, borderRadius: 13, - backgroundColor: '#EF4444', justifyContent: 'center', alignItems: 'center', - shadowColor: '#EF4444', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 6, + backgroundColor: Colors.destructive, justifyContent: 'center', alignItems: 'center', + shadowColor: Colors.destructive, shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 6, }, closeTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, }); diff --git a/front/src/screens/Home/HomeScreen.js b/front/src/screens/Home/HomeScreen.js index a5117ff..35ce1b1 100644 --- a/front/src/screens/Home/HomeScreen.js +++ b/front/src/screens/Home/HomeScreen.js @@ -19,7 +19,7 @@ import { recommendNextMuscleGroup, aggregateGlobal, xpToLevel, -} from '../../services/stats.service'; +} from '../../services'; import { findMuscleGroup } from '../../constants/exerciseFilters'; import { TEMPLATES, @@ -35,7 +35,7 @@ import DailyQuestsCard from '../../components/home/DailyQuestsCard'; import RecoveryRitualsCard from '../../components/home/RecoveryRitualsCard'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; -import { useDevSettings } from '../../hooks/useDevSettings'; +import { useDevSettings } from '../../hooks'; import { MOCK_TUTORIAL_LOGS } from '../../data/mockTutorialStats'; // Écran d'accueil. Bascule entre EmptyHomeState (compte vierge) et état actif @@ -189,7 +189,7 @@ export default function HomeScreen({ navigation }) { {isTutorialDashboard && ( - + Données de démonstration - disparaîtront à la fin du chapitre )} @@ -340,7 +340,7 @@ const styles = StyleSheet.create({ borderBottomWidth: 1, borderBottomColor: 'rgba(255,215,0,0.20)', paddingHorizontal: 16, paddingVertical: 8, }, - mockBannerText: { color: '#FFD700', fontSize: 11, fontWeight: '600', flex: 1 }, + mockBannerText: { color: Colors.gold, fontSize: 11, fontWeight: '600', flex: 1 }, scroll: { flex: 1 }, scrollContent: { paddingHorizontal: 20, diff --git a/front/src/screens/Profile/EditProfileScreen.js b/front/src/screens/Profile/EditProfileScreen.js index 841c202..e45a0ba 100644 --- a/front/src/screens/Profile/EditProfileScreen.js +++ b/front/src/screens/Profile/EditProfileScreen.js @@ -10,10 +10,10 @@ import { } from 'react-native'; import { Colors } from '../../constants/theme'; -import API from '../../api/api'; +import { updateMyProfile } from '../../services'; import { useUser } from '../../context/UserContext'; import { useToast } from '../../context/ToastContext'; -import { setBirthdate } from '../../services/reward.service'; +import { setBirthdate } from '../../services'; import BirthdatePicker from '../../components/profile/BirthdatePicker'; // ─── EditProfileScreen ──────────────────────────────────────────────────────── @@ -107,8 +107,8 @@ export default function EditProfileScreen({ navigation }) { ...(parsedRythme !== null && { rythme: parsedRythme }), }; try { - const res = await API.put('/users/me', payload); - if (res.data.success) { + const res = await updateMyProfile(payload); + if (res.success) { refetchUser(); // met à jour le UserContext → ProfileScreen + SettingsScreen sync instantané showToast('Profil mis à jour avec succès !', 'success'); navigation.goBack(); @@ -351,7 +351,7 @@ const GRP_BORDER = 'rgba(255,255,255,0.09)'; const SEP = 'rgba(255,255,255,0.07)'; const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#080910' }, + root: { flex: 1, backgroundColor: Colors.bgAbyss }, scroll: { flex: 1 }, scrollContent: { paddingHorizontal: 16, paddingTop: 8 }, diff --git a/front/src/screens/Profile/InventoryScreen.js b/front/src/screens/Profile/InventoryScreen.js index 6796bdc..73a2a3f 100644 --- a/front/src/screens/Profile/InventoryScreen.js +++ b/front/src/screens/Profile/InventoryScreen.js @@ -9,12 +9,12 @@ import { Colors } from '../../constants/theme'; import { useUser } from '../../context/UserContext'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import { useToast } from '../../context/ToastContext'; -import { openChest, useItem, claimUniqueItem, ITEM_CATALOG, RARITY_META } from '../../services/inventory.service'; -import { xpForLevel, xpToLevel } from '../../services/stats.service'; -import { useAvatarFrame } from '../../hooks/useAvatarFrame'; -import { useDevSettings } from '../../hooks/useDevSettings'; +import { openChest, useItem, claimUniqueItem, ITEM_CATALOG, RARITY_META } from '../../services'; +import { xpForLevel, xpToLevel } from '../../services'; +import { useAvatarFrame } from '../../hooks'; +import { useDevSettings } from '../../hooks'; import ChestOpeningModal from '../../components/inventory/ChestOpeningModal'; -import { haptics } from '../../services/haptics.service'; +import { haptics } from '../../services'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; diff --git a/front/src/screens/Profile/ProfileScreen.js b/front/src/screens/Profile/ProfileScreen.js index 337a4b8..01f4864 100644 --- a/front/src/screens/Profile/ProfileScreen.js +++ b/front/src/screens/Profile/ProfileScreen.js @@ -23,16 +23,16 @@ import { xpToLevel, computeStreak, getRank, -} from '../../services/stats.service'; +} from '../../services'; import { resolveExerciseMeta } from '../../data/majorExercises'; -import { getMyRecords } from '../../services/social.service'; -import { getTitles } from '../../services/title.service'; -import { haptics } from '../../services/haptics.service'; -import { RARITY_META } from '../../services/inventory.service'; -import { syncLocalAchievements } from '../../services/reward.service'; -import { useAvatarFrame } from '../../hooks/useAvatarFrame'; -import { useDevSettings } from '../../hooks/useDevSettings'; -import { useFeaturedTrophies } from '../../hooks/useFeaturedTrophies'; +import { getMyRecords } from '../../services'; +import { getTitles } from '../../services'; +import { haptics } from '../../services'; +import { RARITY_META } from '../../services'; +import { syncLocalAchievements } from '../../services'; +import { useAvatarFrame } from '../../hooks'; +import { useDevSettings } from '../../hooks'; +import { useFeaturedTrophies } from '../../hooks'; import { getTheme } from '../../data/profileThemes'; import { evaluateTrophies, ULTIMATE_TROPHY } from '../../data/trophyCatalog'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; @@ -296,7 +296,7 @@ export default function ProfileScreen({ navigation }) { /> diff --git a/front/src/screens/Profile/RankRoadmapScreen.js b/front/src/screens/Profile/RankRoadmapScreen.js index d03c56d..2545907 100644 --- a/front/src/screens/Profile/RankRoadmapScreen.js +++ b/front/src/screens/Profile/RankRoadmapScreen.js @@ -10,7 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { Colors } from '../../constants/theme'; -import { getRank, xpToLevel } from '../../services/stats.service'; +import { xpToLevel } from '../../services'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import { FRAME_DEFS } from '../../components/profile/AvatarFrame'; @@ -22,7 +22,7 @@ const RANKS = [ levelRange: '0 – 10', minLevel: 0, maxLevel: 10, - color: '#FE7439', + color: Colors.primary, gradientColors: ['#431A08', '#7C2D12'], frameId: 'none', perks: [ @@ -53,7 +53,7 @@ const RANKS = [ levelRange: '31 – 50', minLevel: 31, maxLevel: 50, - color: '#22C55E', + color: Colors.valid, gradientColors: ['#052E16', '#14532D'], frameId: 'silver', perks: [ @@ -82,7 +82,7 @@ const RANKS = [ levelRange: '71 – 90', minLevel: 71, maxLevel: 90, - color: '#6E6AF0', + color: Colors.secondaryAccent, gradientColors: ['#1E1B4B', '#2E2A7A'], frameId: 'warrior', perks: [ @@ -96,7 +96,7 @@ const RANKS = [ levelRange: '91 – 110', minLevel: 91, maxLevel: 110, - color: '#6E6AF0', + color: Colors.secondaryAccent, gradientColors: ['#0F0F1A', '#1C1C38'], frameId: 'elite', perks: [ @@ -111,7 +111,7 @@ const RANKS = [ levelRange: '111 – 140', minLevel: 111, maxLevel: 140, - color: '#8B5CF6', + color: Colors.rankViolet, gradientColors: ['#2E1065', '#4C1D95'], frameId: 'master', perks: [ @@ -125,7 +125,7 @@ const RANKS = [ levelRange: '141 – 170', minLevel: 141, maxLevel: 170, - color: '#A855F7', + color: Colors.rankPurple, gradientColors: ['#3B0764', '#581C87'], frameId: 'grandmaster', perks: [ @@ -139,7 +139,7 @@ const RANKS = [ levelRange: '171 – 199', minLevel: 171, maxLevel: 199, - color: '#C084FC', + color: Colors.legendAccent, gradientColors: ['#4A044E', '#701A75'], frameId: 'legend', perks: [ @@ -156,7 +156,7 @@ const RANKS = [ levelRange: 'Niv. 200', minLevel: 200, maxLevel: 200, - color: '#FFD700', + color: Colors.gold, gradientColors: ['#431407', '#78350F'], frameId: 'god', perks: [ diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 4511499..d3326fa 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -6,29 +6,29 @@ import { import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Colors } from '../../constants/theme'; -import ConfirmModal from '../../components/common/ConfirmModal'; -import InfoModal from '../../components/common/InfoModal'; +import { ConfirmModal } from '../../components/common'; +import { InfoModal } from '../../components/common'; import { useAuth } from '../../context/AuthContext'; import { useUser } from '../../context/UserContext'; import { useToast } from '../../context/ToastContext'; import { useSavedWorkouts } from '../../context/SavedWorkoutsContext'; import { useQuests } from '../../context/QuestContext'; import { useCustomExercises } from '../../context/CustomExercisesContext'; -import { deleteAccount } from '../../services/auth.service'; +import { deleteAccount } from '../../services'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import { xpToLevel, xpForLevel, computeStreak, debugAddXP, debugSetLevel, debugAddSessions, debugSimulateReps, debugSetStreak, debugClearDebugLogs, debugResetDailyXP, -} from '../../services/stats.service'; +} from '../../services'; import { PROFILE_THEMES, isThemeLocked } from '../../data/profileThemes'; import { TROPHY_CATALOG, TROPHY_CATEGORIES, ULTIMATE_TROPHY, evaluateTrophies } from '../../data/trophyCatalog'; import { COLLECTION_CATEGORY, BACKEND_CATEGORY_MAP } from '../../data/backendTrophyCategories'; -import { getAchievements } from '../../services/reward.service'; +import { getAchievements } from '../../services'; import { normalizeAchievement } from '../../components/profile/AchievementShowcase'; import { TrophyIcon } from '../../components/profile/TrophySlot'; -import { useDevSettings } from '../../hooks/useDevSettings'; +import { useDevSettings } from '../../hooks'; import { useFocusEffect } from '@react-navigation/native'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; @@ -38,13 +38,13 @@ import { fireTestNotification, scheduleDailyReminder, cancelDailyReminder, -} from '../../services/notificationService'; +} from '../../services'; import { syncBackendLevel, giveChests, generateMockSocial, giveAllItems, simulateChestsOpened, simulateReferral, simulateBirthday, simulateGroup, simulateActivityEvent, simulateStreakBreak, simulateShakeSelf, simulateSearchableFriend, simulateLobbyInvite, giveAllTitles, -} from '../../services/debug.service'; +} from '../../services'; const UNIT_WEIGHT_KEY = 'athly:unit:weight:v1'; const UNIT_DIST_KEY = 'athly:unit:distance:v1'; @@ -1208,7 +1208,7 @@ export default function SettingsScreen({ navigation }) { {/* ─── Suppression définitive du compte ────────────────────────────── */} setDeleteModal1(true)} activeOpacity={0.7}> - + Supprimer le compte @@ -1224,7 +1224,7 @@ export default function SettingsScreen({ navigation }) { - + Bienvenue à bord ! @@ -1254,7 +1254,7 @@ export default function SettingsScreen({ navigation }) { - + Êtes-vous sûr ? @@ -1280,7 +1280,7 @@ export default function SettingsScreen({ navigation }) { - + Confirmation finale @@ -1440,7 +1440,7 @@ function DevBtn({ label, onPress, disabled, variant = 'default', flex, fullWidth // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#080910' }, + root: { flex: 1, backgroundColor: Colors.bgAbyss }, scroll: { flex: 1 }, scrollContent: { paddingHorizontal: 16, paddingTop: 8 }, @@ -1482,7 +1482,7 @@ const styles = StyleSheet.create({ themeItemSel: { backgroundColor: 'rgba(255,255,255,0.07)', borderColor: 'rgba(255,255,255,0.25)' }, themeItemLocked: { opacity: 0.35 }, themeSwatch: { width: 32, height: 32, borderRadius: 16, marginBottom: 5 }, - themeCheck: { position: 'absolute', top: 6, right: 6, width: 14, height: 14, borderRadius: 7, backgroundColor: Colors.primary, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: '#13131C' }, + themeCheck: { position: 'absolute', top: 6, right: 6, width: 14, height: 14, borderRadius: 7, backgroundColor: Colors.primary, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: Colors.bgDeep2 }, themeLockOverlay:{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' }, themeLabel: { color: Colors.textMuted, fontSize: 8, fontWeight: '600', textAlign: 'center' }, themeLabelLocked:{ color: Colors.textMuted }, @@ -1546,7 +1546,7 @@ const styles = StyleSheet.create({ devBtnTextDestructive: { color: Colors.error }, devBtnTextDim: { color: Colors.textMuted }, devBtnTextOrange: { color: '#FF6B00' }, - devBtnTextViolet: { color: '#8B5CF6' }, + devBtnTextViolet: { color: Colors.rankViolet }, devBtnTextDisabled: { color: Colors.textMuted }, devHint: { color: 'rgba(255,215,0,0.40)', fontSize: 10, fontWeight: '500', lineHeight: 14, marginTop: -4 }, @@ -1605,16 +1605,16 @@ const styles = StyleSheet.create({ // ── Welcome modal (fin tutoriel) ───────────────────────────────────────────── welcomeCard: { borderColor: 'rgba(110,106,240,0.30)' }, welcomeIconWrap:{ width: 64, height: 64, borderRadius: 20, backgroundColor: 'rgba(110,106,240,0.12)', borderWidth: 1, borderColor: 'rgba(110,106,240,0.30)', justifyContent: 'center', alignItems: 'center', marginBottom: 18 }, - welcomeBtn: { flexDirection: 'row', alignItems: 'center', width: '100%', height: 50, borderRadius: 13, backgroundColor: '#6E6AF0', justifyContent: 'center', marginBottom: 10, shadowColor: '#6E6AF0', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.40, shadowRadius: 12, elevation: 6 }, + welcomeBtn: { flexDirection: 'row', alignItems: 'center', width: '100%', height: 50, borderRadius: 13, backgroundColor: Colors.secondaryAccent, justifyContent: 'center', marginBottom: 10, shadowColor: Colors.secondaryAccent, shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.40, shadowRadius: 12, elevation: 6 }, welcomeBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, // ── Delete Account button ──────────────────────────────────────────────────── deleteAccountBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginTop: 12, paddingVertical: 14, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(239,68,68,0.15)', backgroundColor: 'transparent' }, - deleteAccountText: { color: '#FF4D4D', fontSize: 13, fontWeight: '600', opacity: 0.75 }, + deleteAccountText: { color: Colors.error, fontSize: 13, fontWeight: '600', opacity: 0.75 }, // ── Delete Account Modals ──────────────────────────────────────────────────── dmBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.82)', justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, - dmCard: { width: '100%', backgroundColor: '#13131C', borderRadius: 22, borderWidth: 1, borderColor: 'rgba(239,68,68,0.22)', padding: 28, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.65, shadowRadius: 32, elevation: 20 }, + dmCard: { width: '100%', backgroundColor: Colors.bgDeep2, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(239,68,68,0.22)', padding: 28, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.65, shadowRadius: 32, elevation: 20 }, dmCardFinal: { borderColor: 'rgba(220,38,38,0.35)' }, dmIconWrap: { width: 60, height: 60, borderRadius: 18, backgroundColor: 'rgba(239,68,68,0.10)', borderWidth: 1, borderColor: 'rgba(239,68,68,0.25)', justifyContent: 'center', alignItems: 'center', marginBottom: 18 }, dmIconFinalWrap:{ width: 60, height: 60, borderRadius: 18, backgroundColor: 'rgba(220,38,38,0.14)', borderWidth: 1, borderColor: 'rgba(220,38,38,0.35)', justifyContent: 'center', alignItems: 'center', marginBottom: 18 }, @@ -1623,7 +1623,7 @@ const styles = StyleSheet.create({ dmKeepBtn: { flexDirection: 'row', alignItems: 'center', width: '100%', height: 50, borderRadius: 13, backgroundColor: Colors.primary, justifyContent: 'center', marginBottom: 10, shadowColor: Colors.primary, shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 6 }, dmKeepTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, dmContinueBtn: { width: '100%', height: 44, borderRadius: 13, justifyContent: 'center', alignItems: 'center' }, - dmContinueTxt: { color: '#EF4444', fontSize: 14, fontWeight: '600' }, + dmContinueTxt: { color: Colors.destructive, fontSize: 14, fontWeight: '600' }, dmDestroyBtn: { flexDirection: 'row', alignItems: 'center', width: '100%', height: 50, borderRadius: 13, backgroundColor: '#DC2626', justifyContent: 'center', marginBottom: 10, shadowColor: '#DC2626', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.45, shadowRadius: 12, elevation: 6 }, dmDestroyTxt: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.2 }, dmBackBtn: { width: '100%', height: 44, borderRadius: 13, justifyContent: 'center', alignItems: 'center' }, diff --git a/front/src/screens/Profile/TrophyRoomScreen.js b/front/src/screens/Profile/TrophyRoomScreen.js index 0b25209..90f73c4 100644 --- a/front/src/screens/Profile/TrophyRoomScreen.js +++ b/front/src/screens/Profile/TrophyRoomScreen.js @@ -17,7 +17,7 @@ import { TROPHY_FILTER_TABS, evaluateTrophies, } from '../../data/trophyCatalog'; -import { useFeaturedTrophies, MAX_FEATURED } from '../../hooks/useFeaturedTrophies'; +import { useFeaturedTrophies } from '../../hooks/useFeaturedTrophies'; import { useFocusEffect } from '@react-navigation/native'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; @@ -100,7 +100,7 @@ function UltimateTile({ unlocked, unlockedCount, totalCount, onPress }) { styles.ultimateTile, unlocked && { borderColor: 'rgba(255,215,0,0.6)', - borderTopColor: '#FFD700', + borderTopColor: Colors.gold, }, { transform: [{ scale }] }, ]}> @@ -118,7 +118,7 @@ function UltimateTile({ unlocked, unlockedCount, totalCount, onPress }) { {/* Icon */} {unlocked ? ( - + {ULTIMATE_TROPHY.label} @@ -150,7 +150,7 @@ function UltimateTile({ unlocked, unlockedCount, totalCount, onPress }) { {unlocked && ( - + DIAMOND · ULTIME )} @@ -322,9 +322,9 @@ export default function TrophyRoomScreen({ navigation }) { - + - Trophée Ultime + Trophée Ultime {ultimateUnlocked ? '1/1' : '0/1'} @@ -568,7 +568,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, paddingVertical: 4, alignSelf: 'flex-start', borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)', }, - ultimateBadgeText: { color: '#FFD700', fontSize: 9, fontWeight: '900', letterSpacing: 1 }, + ultimateBadgeText: { color: Colors.gold, fontSize: 9, fontWeight: '900', letterSpacing: 1 }, ultimateHint: { color: 'rgba(255,255,255,0.2)', fontSize: 10, fontWeight: '500', textAlign: 'center', marginTop: 12 }, // ── Tile ────────────────────────────────────────────────────────────────── diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index d86931d..6e738fb 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -8,9 +8,9 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { useToast } from '../../context/ToastContext'; -import { getRank, xpToLevel } from '../../services/stats.service'; -import { getFriendProfile, getMyGroup, shakeMember } from '../../services/social.service'; -import { RARITY_META } from '../../services/inventory.service'; +import { getRank, xpToLevel } from '../../services'; +import { getFriendProfile, getMyGroup, shakeMember } from '../../services'; +import { RARITY_META } from '../../services'; import HeroLevelCard from '../../components/profile/HeroLevelCard'; import StreakBadge from '../../components/profile/StreakBadge'; @@ -174,7 +174,7 @@ export default function FriendProfileScreen({ route, navigation }) { /> diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index 23289c3..ac12fd4 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -9,7 +9,7 @@ import { Colors } from '../../constants/theme'; import { useToast } from '../../context/ToastContext'; import { useUser } from '../../context/UserContext'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; -import ConfirmModal from '../../components/common/ConfirmModal'; +import { ConfirmModal } from '../../components/common'; import FriendshipLevelUpModal from '../../components/social/FriendshipLevelUpModal'; import FriendPreviewModal from '../../components/social/FriendPreviewModal'; import AddFriendModal from '../../components/social/AddFriendModal'; @@ -18,7 +18,7 @@ import { cancelFriendRequest, removeFriend, getFriendsList, getPendingRequests, getLeaderboard, getExerciseLeaderboard, getMyGroup, inviteToGroup, respondToGroupInvite, shakeMember, checkGroupStreak, leaveGroup, -} from '../../services/social.service'; +} from '../../services'; import { MAJOR_EXERCISES } from '../../data/majorExercises'; import ExercisePickerModal from '../../components/social/ExercisePickerModal'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; @@ -26,7 +26,7 @@ import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; // Podium / classements : positions 1-3 affichées en médaille colorée plutôt // qu'en emoji 🥇🥈🥉. -const MEDAL_COLORS = { 1: '#FFD700', 2: '#C0C0C0', 3: '#CD7F32' }; +const MEDAL_COLORS = { 1: Colors.gold, 2: '#C0C0C0', 3: '#CD7F32' }; function MedalBadge({ position, size = 16 }) { const color = MEDAL_COLORS[position]; @@ -439,7 +439,7 @@ function FriendsSegment({ // ═══ Segment Classement ═══════════════════════════════════════════════════════ -const PODIUM_COLORS = ['#FFD700', '#C0C0C0', '#CD7F32']; +const PODIUM_COLORS = [Colors.gold, '#C0C0C0', '#CD7F32']; function LeaderboardSegment({ leaderboard }) { const [mode, setMode] = useState('xp'); // 'xp' | 'records' @@ -879,7 +879,7 @@ function AnimatedRow({ index, children }) { // dans `member.weatherStatus`. Affiché uniquement quand présent (les listes // amis/requêtes n'en portent pas — seuls les membres de groupe en ont un). const WEATHER_META = { - done: { icon: 'checkmark-circle', color: '#22C55E', label: 'Séance validée' }, + done: { icon: 'checkmark-circle', color: Colors.valid, label: 'Séance validée' }, active: { icon: 'flash', color: '#FBBF24', label: 'Séance active' }, ready: { icon: 'flame', color: Colors.primary, label: 'Prêt' }, sleeping: { icon: 'moon', color: Colors.textMuted, label: 'En sommeil' }, diff --git a/front/src/screens/Stats/StatsScreen.js b/front/src/screens/Stats/StatsScreen.js index 6c49d54..2f8252d 100644 --- a/front/src/screens/Stats/StatsScreen.js +++ b/front/src/screens/Stats/StatsScreen.js @@ -8,8 +8,8 @@ import { useFocusEffect } from '@react-navigation/native'; import { Colors } from '../../constants/theme'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; import { useUser } from '../../context/UserContext'; -import { aggregateGlobal } from '../../services/stats.service'; -import { getWeightHistory } from '../../services/weight.service'; +import { aggregateGlobal } from '../../services'; +import { getWeightHistory } from '../../services'; import PeriodSegmentedControl from '../../components/stats/PeriodSegmentedControl'; import VolumeBarChart from '../../components/stats/VolumeBarChart'; import MuscleDistributionPieChart from '../../components/stats/MuscleDistributionPieChart'; @@ -18,9 +18,9 @@ import WorkoutCalendar from '../../components/stats/WorkoutCalendar'; import XPProgressBar from '../../components/stats/XPProgressBar'; import WorkoutHistoryList from '../../components/stats/WorkoutHistoryList'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; -import WeightEntryModal from '../../components/common/WeightEntryModal'; -import ConfirmModal from '../../components/common/ConfirmModal'; -import InfoModal from '../../components/common/InfoModal'; +import { WeightEntryModal } from '../../components/common'; +import { ConfirmModal } from '../../components/common'; +import { InfoModal } from '../../components/common'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; import { MOCK_TUTORIAL_LOGS } from '../../data/mockTutorialStats'; @@ -189,7 +189,7 @@ export default function StatsScreen({ navigation }) { {/* Bandeau mock data */} {activeChapterId === 'stats' && ( - + Données de démonstration - disparaîtront à la fin du chapitre )} @@ -368,7 +368,7 @@ const styles = StyleSheet.create({ borderBottomWidth: 1, borderBottomColor: 'rgba(255,215,0,0.25)', paddingHorizontal: 16, paddingVertical: 8, }, - mockBannerText: { color: '#FFD700', fontSize: 11, fontWeight: '600', flex: 1 }, + mockBannerText: { color: Colors.gold, fontSize: 11, fontWeight: '600', flex: 1 }, header: { marginBottom: 16 }, title: { color: Colors.textPrimary, fontSize: 26, fontWeight: '900', letterSpacing: -0.5 }, diff --git a/front/src/screens/Workouts/CustomExercisesScreen.js b/front/src/screens/Workouts/CustomExercisesScreen.js index 6ab409c..0e163b5 100644 --- a/front/src/screens/Workouts/CustomExercisesScreen.js +++ b/front/src/screens/Workouts/CustomExercisesScreen.js @@ -16,8 +16,8 @@ import { secondaryMusclesLabels, primaryEquipmentLabel, } from '../../constants/exerciseFilters'; -import ConfirmModal from '../../components/common/ConfirmModal'; -import InfoModal from '../../components/common/InfoModal'; +import { ConfirmModal } from '../../components/common'; +import { InfoModal } from '../../components/common'; export default function CustomExercisesScreen({ navigation }) { const { items, loading, remove } = useCustomExercises(); @@ -149,7 +149,7 @@ const styles = StyleSheet.create({ paddingTop: 40, paddingBottom: 14, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#1f1f27', + borderBottomColor: Colors.borderSubtle, }, headerTitle: { color: Colors.textPrimary, @@ -174,7 +174,7 @@ const styles = StyleSheet.create({ width: 50, height: 50, borderRadius: 12, - backgroundColor: '#0e0e12', + backgroundColor: Colors.cardInner, alignItems: 'center', justifyContent: 'center', marginRight: 12, diff --git a/front/src/screens/Workouts/EditExerciseScreen.js b/front/src/screens/Workouts/EditExerciseScreen.js index bd0e057..e519796 100644 --- a/front/src/screens/Workouts/EditExerciseScreen.js +++ b/front/src/screens/Workouts/EditExerciseScreen.js @@ -17,8 +17,8 @@ import { EQUIPMENTS, LEVELS } from '../../constants/exerciseFilters'; import SelectableChip from '../../components/workouts/SelectableChip'; import MuscleHierarchyPicker from '../../components/workouts/MuscleHierarchyPicker'; import { useCustomExercises } from '../../context/CustomExercisesContext'; -import ConfirmModal from '../../components/common/ConfirmModal'; -import InfoModal from '../../components/common/InfoModal'; +import { ConfirmModal } from '../../components/common'; +import { InfoModal } from '../../components/common'; // Form add/edit d'un exercice perso. Aligné sur la structure du catalogue (sous-muscles // précis), donc directement compatible avec le Builder et l'algo de tri. @@ -303,7 +303,7 @@ const styles = StyleSheet.create({ paddingTop: 40, paddingBottom: 14, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#1f1f27', + borderBottomColor: Colors.borderSubtle, }, headerTitle: { color: Colors.textPrimary, diff --git a/front/src/screens/Workouts/ExerciseDetailScreen.js b/front/src/screens/Workouts/ExerciseDetailScreen.js index 6aaee82..b8490e0 100644 --- a/front/src/screens/Workouts/ExerciseDetailScreen.js +++ b/front/src/screens/Workouts/ExerciseDetailScreen.js @@ -18,7 +18,7 @@ import { primaryMuscleLabel, secondaryMusclesLabels, } from '../../constants/exerciseFilters'; -import useEffortTimer from '../../hooks/useEffortTimer'; +import { useEffortTimer } from '../../hooks'; import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext'; import ExerciseHeader from '../../components/workouts/ExerciseHeader'; import SetTable from '../../components/workouts/SetTable'; diff --git a/front/src/screens/Workouts/ExerciseStatsScreen.js b/front/src/screens/Workouts/ExerciseStatsScreen.js index c08cd4c..d2f51d4 100644 --- a/front/src/screens/Workouts/ExerciseStatsScreen.js +++ b/front/src/screens/Workouts/ExerciseStatsScreen.js @@ -10,7 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; -import { aggregateExercise } from '../../services/stats.service'; +import { aggregateExercise } from '../../services'; import ExerciseStatsChart from '../../components/stats/ExerciseStatsChart'; import PRCard from '../../components/stats/PRCard'; diff --git a/front/src/screens/Workouts/ManualWorkoutCreatorScreen.js b/front/src/screens/Workouts/ManualWorkoutCreatorScreen.js index a4ea4f2..1375d82 100644 --- a/front/src/screens/Workouts/ManualWorkoutCreatorScreen.js +++ b/front/src/screens/Workouts/ManualWorkoutCreatorScreen.js @@ -327,7 +327,7 @@ const styles = StyleSheet.create({ paddingTop: 40, paddingBottom: 14, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#1f1f27', + borderBottomColor: Colors.borderSubtle, }, headerTitle: { color: Colors.textPrimary, @@ -403,7 +403,7 @@ const styles = StyleSheet.create({ width: 44, height: 44, borderRadius: 10, - backgroundColor: '#0e0e12', + backgroundColor: Colors.cardInner, alignItems: 'center', justifyContent: 'center', marginRight: 12, @@ -457,7 +457,7 @@ const styles = StyleSheet.create({ stepper: { flexDirection: 'row', alignItems: 'center', - backgroundColor: '#0e0e12', + backgroundColor: Colors.cardInner, borderRadius: 10, paddingHorizontal: 4, }, @@ -475,7 +475,7 @@ const styles = StyleSheet.create({ textAlign: 'center', }, repsInput: { - backgroundColor: '#0e0e12', + backgroundColor: Colors.cardInner, color: Colors.textPrimary, fontSize: 14, fontWeight: '700', @@ -515,7 +515,7 @@ const styles = StyleSheet.create({ paddingBottom: 26, backgroundColor: Colors.background, borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#1f1f27', + borderTopColor: Colors.borderSubtle, }, saveBtn: { flexDirection: 'row', diff --git a/front/src/screens/Workouts/WorkoutBuilderScreen.js b/front/src/screens/Workouts/WorkoutBuilderScreen.js index 2495497..a22d34d 100644 --- a/front/src/screens/Workouts/WorkoutBuilderScreen.js +++ b/front/src/screens/Workouts/WorkoutBuilderScreen.js @@ -281,7 +281,7 @@ const styles = StyleSheet.create({ paddingTop: 40, paddingBottom: 14, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#1f1f27', + borderBottomColor: Colors.borderSubtle, }, headerTitle: { color: Colors.textPrimary, @@ -387,7 +387,7 @@ const styles = StyleSheet.create({ width: 38, height: 38, borderRadius: 9, - backgroundColor: '#0e0e12', + backgroundColor: Colors.cardInner, alignItems: 'center', justifyContent: 'center', marginRight: 10, @@ -418,7 +418,7 @@ const styles = StyleSheet.create({ paddingBottom: 26, backgroundColor: Colors.background, borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#1f1f27', + borderTopColor: Colors.borderSubtle, }, launchBtn: { flexDirection: 'row', diff --git a/front/src/screens/Workouts/WorkoutScreen.js b/front/src/screens/Workouts/WorkoutScreen.js index 1099541..82f548c 100644 --- a/front/src/screens/Workouts/WorkoutScreen.js +++ b/front/src/screens/Workouts/WorkoutScreen.js @@ -10,15 +10,15 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; -import { haptics } from '../../services/haptics.service'; +import { haptics } from '../../services'; import { Colors } from '../../constants/theme'; -import useExerciseSorting, { isFiltering } from '../../hooks/useExerciseSorting'; +import { useExerciseSorting, isFiltering } from '../../hooks'; import { useWorkoutInProgress } from '../../context/WorkoutInProgressContext'; import { useWorkoutLogs } from '../../context/WorkoutLogsContext'; -import { useDevSettings } from '../../hooks/useDevSettings'; +import { useDevSettings } from '../../hooks'; -import API from '../../api/api'; +import { completeWorkout } from '../../services'; import SortBar from '../../components/workouts/SortBar'; import SupersetGroup from '../../components/workouts/SupersetGroup'; @@ -27,12 +27,12 @@ import InlineExerciseBlock from '../../components/workouts/InlineExerciseBlock'; import AddExerciseSheet from '../../components/workouts/AddExerciseSheet'; import WorkoutRecapModal from '../../components/workouts/WorkoutRecapModal'; import ShortSessionWarningModal from '../../components/workouts/ShortSessionWarningModal'; -import QuestToast from '../../components/common/QuestToast'; -import ConfirmModal from '../../components/common/ConfirmModal'; +import { QuestToast } from '../../components/common'; +import { ConfirmModal } from '../../components/common'; import LobbyMembersBar from '../../components/workouts/LobbyMembersBar'; import LobbyWaitingOverlay from '../../components/workouts/LobbyWaitingOverlay'; import MultiLootModal from '../../components/workouts/MultiLootModal'; -import { getLobby, finishLobby } from '../../services/lobby.service'; +import { getLobby, finishLobby } from '../../services'; const DEFAULT_FILTERS = { muscles: [], levels: [], equipment: [] }; @@ -266,7 +266,7 @@ export default function WorkoutScreen({ route, navigation }) { const result = await actions.finalize({ notes: state.notes, durationSeconds: elapsed, ...opts }); if (state.id) { - API.post(`/workouts/${state.id}/complete`).catch(() => {}); + completeWorkout(state.id).catch(() => {}); } const builtRecapData = { diff --git a/front/src/services/exercise.service.js b/front/src/services/exercise.service.js deleted file mode 100644 index 39400d8..0000000 --- a/front/src/services/exercise.service.js +++ /dev/null @@ -1,9 +0,0 @@ -import API from "../api/api"; - -export function getExerciseRecords(workoutId) { - return API.get(`/exercise/workout/${workoutId}`); -} - -export function addExerciseRecord(data) { - return API.post("/exercise", data); -} \ No newline at end of file diff --git a/front/src/services/index.js b/front/src/services/index.js new file mode 100644 index 0000000..b08f3b8 --- /dev/null +++ b/front/src/services/index.js @@ -0,0 +1,25 @@ +// ─── Barrel des services ────────────────────────────────────────────────────── +// Point d'entrée unique : `import { getFriendsList, haptics } from '../services';` +// Tous les services n'exportent que des fonctions/constantes nommées, sans +// collision de noms (vérifié) — l'agrégation en `export *` est donc sûre. +// Règle : ne jamais importer ce barrel DEPUIS un fichier de ce dossier +// (import direct entre services) pour exclure tout cycle de dépendances. + +export * from './auth.service'; +export * from './customExercises.service'; +export * from './debug.service'; +export * from './haptics.service'; +export * from './inventory.service'; +export * from './lobby.service'; +export * from './notificationService'; +export * from './onboarding.service'; +export * from './profile.service'; +export * from './quest.service'; +export * from './reward.service'; +export * from './savedWorkouts.service'; +export * from './social.service'; +export * from './stats.service'; +export * from './title.service'; +export * from './weight.service'; +export * from './workouts.service'; +export * from './xpSync.service'; diff --git a/front/src/services/profile.service.js b/front/src/services/profile.service.js index a5314ec..422ac04 100644 --- a/front/src/services/profile.service.js +++ b/front/src/services/profile.service.js @@ -1,5 +1,12 @@ import API from '../api/api'; +// Met à jour les informations du profil (nom, mensurations, objectif, rythme…). +// Renvoie la réponse brute — l'appelant gère le refetch du UserContext. +export async function updateMyProfile(payload) { + const res = await API.put('/users/me', payload); + return res.data; +} + // Synchronise le cadre de profil équipé (forme + couleur) vers le backend, // pour qu'il soit visible sur le profil public par les amis (useAvatarFrame // reste la source de vérité locale pour l'affichage instantané côté soi-même). diff --git a/front/src/services/workout.service.js b/front/src/services/workout.service.js deleted file mode 100644 index a56a6a5..0000000 --- a/front/src/services/workout.service.js +++ /dev/null @@ -1,28 +0,0 @@ -import API from '../api/api'; - -export async function getWorkouts() { - try { - const response = await API.get('/workouts'); - return response.data.workouts; - } catch { - return []; - } -} - -export async function getWorkout(id) { - try { - const response = await API.get(`/workouts/${id}`); - return response.data.workout; - } catch { - return null; - } -} - -export async function createWorkout(workout) { - try { - const response = await API.post('/workouts', workout); - return response.data.workout; - } catch { - return null; - } -} diff --git a/front/src/services/workouts.service.js b/front/src/services/workouts.service.js new file mode 100644 index 0000000..adb00b9 --- /dev/null +++ b/front/src/services/workouts.service.js @@ -0,0 +1,36 @@ +import API from '../api/api'; + +// ─── Cycle de vie des séances côté backend ──────────────────────────────────── +// Draft (auto-save pendant la séance) → finalize (calcul XP/records serveur) +// → complete (marquage léger fire-and-forget). Consommé par useWorkoutState +// (draft/finalize) et WorkoutScreen (complete) — les écrans et hooks ne +// touchent jamais l'API brute directement. + +// Crée le brouillon serveur de la séance en cours (première sauvegarde). +export async function createWorkoutDraft({ name, exercises, notes }) { + const res = await API.post('/workouts/draft', { + name, exercises, notes, status: 'draft', + }); + const data = res && res.data ? res.data : res; + return data && data.workout ? data.workout : null; +} + +// Met à jour le brouillon serveur existant (auto-save débouncé). +export async function updateWorkoutDraft(id, { exercises, notes, durationSeconds }) { + const res = await API.patch(`/workouts/${id}/draft`, { + exercises, notes, durationSeconds, + }); + const data = res && res.data ? res.data : res; + return data && data.workout ? data.workout : data; +} + +// Finalise la séance : le backend recalcule XP, records et stats (anti-cheat). +export async function finalizeWorkout(id, payload) { + const res = await API.post(`/workouts/${id}/finalize`, payload); + return res && res.data ? res.data : res; +} + +// Marque la séance comme terminée — best-effort, jamais bloquant. +export function completeWorkout(id) { + return API.post(`/workouts/${id}/complete`); +} diff --git a/front/src/styles/global.js b/front/src/styles/global.js deleted file mode 100644 index e4fcf5e..0000000 --- a/front/src/styles/global.js +++ /dev/null @@ -1,131 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Colors } from '../constants/theme'; - -export const globalStyles = StyleSheet.create({ - // ── Conteneurs ──────────────────────────────────────────────────────────── - safeDeep: { - flex: 1, - backgroundColor: Colors.backgroundDeep, - }, - safeAbyss: { - flex: 1, - backgroundColor: Colors.bgAbyss, - }, - container: { - flex: 1, - backgroundColor: Colors.background, - padding: 16, - }, - scrollContent: { - paddingHorizontal: 20, - paddingBottom: 40, - }, - - // ── Mise en page ────────────────────────────────────────────────────────── - centered: { - justifyContent: 'center', - alignItems: 'center', - }, - rowBetween: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - row: { - flexDirection: 'row', - alignItems: 'center', - }, - flex1: { flex: 1 }, - - // ── Cards ───────────────────────────────────────────────────────────────── - card: { - backgroundColor: Colors.cardDeep, - borderRadius: 16, - padding: 16, - }, - cardLegacy: { - backgroundColor: Colors.card, - borderRadius: 16, - paddingVertical: 14, - paddingHorizontal: 14, - }, - glassCard: { - backgroundColor: Colors.glassBg, - borderWidth: 1, - borderColor: Colors.glassBorder, - borderRadius: 18, - overflow: 'hidden', - }, - - // ── Typographie ─────────────────────────────────────────────────────────── - title: { - color: Colors.textPrimary, - fontSize: 22, - fontWeight: 'bold', - }, - subtitle: { - color: Colors.textSecondary, - fontSize: 14, - marginTop: 4, - }, - sectionLabel: { - color: Colors.textPrimary, - fontSize: 12, - fontWeight: '800', - letterSpacing: 1.2, - textTransform: 'uppercase', - marginBottom: 10, - }, - sectionLinkText: { - color: Colors.primary, - fontSize: 12, - fontWeight: '700', - }, - - // ── Boutons ─────────────────────────────────────────────────────────────── - primaryBtn: { - backgroundColor: Colors.primary, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 15, - shadowColor: Colors.primary, - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.4, - shadowRadius: 14, - elevation: 8, - }, - primaryBtnText: { - color: '#fff', - fontSize: 14, - fontWeight: '800', - }, - ghostBtn: { - borderWidth: 1.5, - borderColor: Colors.primary, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 13, - backgroundColor: 'transparent', - }, - ghostBtnText: { - color: Colors.primary, - fontWeight: '700', - fontSize: 14, - }, - - // ── Écrans vides ────────────────────────────────────────────────────────── - emptyState: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 32, - gap: 14, - }, - emptyText: { - color: Colors.textMuted, - fontSize: 14, - textAlign: 'center', - }, -}); From 7fbe8c81624e860a1d4519cd3fe8e6a2b7048904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Mon, 13 Jul 2026 15:02:26 +0200 Subject: [PATCH 28/31] docs: reecriture des README (racine, front, back) alignee sur l'architecture V2 --- README.md | 216 ++++--- back/README.md | 1447 ++++++++++++++++------------------------------- front/README.md | 871 +++++++++++++--------------- 3 files changed, 1009 insertions(+), 1525 deletions(-) diff --git a/README.md b/README.md index d2ed756..0a16ed3 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@
-# 🏋️ ATHLY +# ATHLY -**Application mobile de fitness gamifiée — React Native + Node.js** +**Application mobile de fitness gamifiée. React Native, Expo, Node.js, MongoDB** ![React Native](https://img.shields.io/badge/React_Native-0.81.5-61DAFB?style=flat-square&logo=react) ![Expo](https://img.shields.io/badge/Expo-54-000020?style=flat-square&logo=expo) -![Node.js](https://img.shields.io/badge/Node.js-22.x-339933?style=flat-square&logo=node.js) +![Node.js](https://img.shields.io/badge/Node.js-20.x-339933?style=flat-square&logo=node.js) ![MongoDB](https://img.shields.io/badge/MongoDB-Atlas-47A248?style=flat-square&logo=mongodb) -![License](https://img.shields.io/badge/License-ISC-blue?style=flat-square) +![License](https://img.shields.io/badge/License-GPLv3-blue?style=flat-square) -> Transformez chaque séance d'entraînement en expérience de jeu. XP, streaks, quêtes quotidiennes, trophées et rituels de récupération — progresser n'a jamais été aussi addictif. +Athly transforme chaque séance d'entraînement en expérience de jeu. XP, niveaux, streaks, quêtes quotidiennes, plus de 60 trophées, 17 titres RPG, coffres à ouvrir, amis, groupes de streak collective et lobby multijoueur : progresser n'a jamais été aussi complet.
@@ -20,25 +20,27 @@ ``` Athly/ -├── front/ ← Application mobile React Native (Expo) -└── back/ ← API REST Node.js / Express / MongoDB + front/ Application mobile et PWA, React Native (Expo) + back/ API REST, Node.js, Express, MongoDB + docs/ Documentation transverse (architecture de sécurité) ``` -Les deux sous-projets ont chacun leur propre README détaillé : -- [📱 Documentation Front-end](front/README.md) — architecture, composants, gamification -- [⚙️ Documentation Back-end](back/README.md) — API REST, modèles, tests +Chaque sous-projet a son propre README détaillé : + +- [Documentation Front-end](front/README.md) : fonctionnalités, architecture, gamification, navigation +- [Documentation Back-end](back/README.md) : API REST complète, modèles de données, tests --- ## Prérequis | Outil | Version minimale | -|-------|-----------------| -| Node.js | ≥ 18 | -| npm | ≥ 9 | +|-------|--------------------| +| Node.js | 18 ou supérieur | +| npm | 9 ou supérieur | | Expo Go (téléphone) | dernière version | | Compte MongoDB Atlas | cluster M0 gratuit suffit | -| Compte SMTP | Brevo, Gmail, Yahoo… | +| Compte SMTP | Brevo, ou équivalent | --- @@ -51,79 +53,74 @@ git clone https://github.com/ClemLy/Athly.git cd Athly ``` -### 2. Configurer le back-end +### 2. Configurer le backend ```bash cd back npm install - -# Créer le fichier de variables d'environnement cp .env.example .env -# → Ouvrir .env et remplir les valeurs (voir section ci-dessous) +# Ouvrir .env et remplir les valeurs, voir la section Variables d'environnement ``` -### 3. Configurer le front-end +### 3. Configurer le frontend ```bash cd ../front npm install - -# Créer le fichier de variables d'environnement cp .env.example .env -# → Renseigner l'IP locale de votre machine (voir section ci-dessous) +# Renseigner l'URL du backend, voir la section suivante ``` --- ## Variables d'environnement +Le détail complet de chaque variable, avec son usage exact, se trouve dans les fichiers `.env.example` de chaque sous-projet. Résumé minimal pour démarrer : + ### `back/.env` ```env -# Serveur PORT=4000 NODE_ENV=development - -# MongoDB Atlas — remplacer par votre URI de connexion MONGO_URI=mongodb+srv://:@.mongodb.net/athly - -# JWT — générer avec : node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" -JWT_SECRET=votre_secret_jwt_long_et_aleatoire +JWT_SECRET=secret_long_et_aleatoire JWT_EXPIRES_IN=1d - -# SMTP — exemple avec Brevo SMTP_HOST=smtp-relay.brevo.com SMTP_PORT=587 SMTP_USER=votre_login_brevo SMTP_PASS=votre_cle_api_brevo SMTP_FROM=noreply@votre-domaine.com +# Optionnel, pour activer la connexion Google : +GOOGLE_CLIENT_IDS= +# Optionnel, pour restreindre le CORS en production : +CORS_ORIGINS= ``` ### `front/.env` ```env -# Adresse IP locale de votre machine (pas localhost — React Native ne le résout pas) -# Sur Windows : ipconfig | grep IPv4 -# Sur macOS/Linux : ifconfig | grep "inet " +# Adresse IP locale de la machine, pas localhost : React Native sur un +# téléphone physique ne peut pas résoudre localhost. API_URL=http://VOTRE_IP_LOCALE:4000/api - -# Clé de stockage du token JWT (SecureStore) TOKEN_KEY=athly_token - APP_ENV=development +# Optionnel, pour activer le bouton Google (un Client ID par plateforme) : +GOOGLE_EXPO_CLIENT_ID= +GOOGLE_IOS_CLIENT_ID= +GOOGLE_ANDROID_CLIENT_ID= +GOOGLE_WEB_CLIENT_ID= ``` -> **Trouver votre IP locale :** -> - Windows : `ipconfig` → chercher "Adresse IPv4" -> - macOS / Linux : `ifconfig` ou `ip addr` → chercher l'adresse en `192.168.x.x` +Trouver son IP locale : `ipconfig` sous Windows, `ifconfig` ou `ip addr` sous macOS et Linux. --- ## Lancer l'application -Ouvrir **deux terminaux** : +Ouvrir deux terminaux. + +**Terminal 1, backend** -**Terminal 1 — Back-end** ```bash cd back npm run dev @@ -131,108 +128,145 @@ npm run dev # Vérification : curl http://localhost:4000/health ``` -**Terminal 2 — Front-end** +**Terminal 2, frontend** + ```bash cd front npm start -# Expo affiche un QR code → scanner avec Expo Go sur votre téléphone +# Expo affiche un QR code à scanner avec Expo Go ``` -> Le téléphone et la machine doivent être sur **le même réseau Wi-Fi**. +Le téléphone et la machine doivent être sur le même réseau Wi-Fi. --- ## Architecture globale ``` -┌─────────────────────────────────────────────────────┐ -│ TÉLÉPHONE (Expo Go) │ -│ │ -│ React Native App │ -│ ├── AuthContext (JWT via SecureStore) │ -│ ├── WorkoutLogsContext (AsyncStorage — source vérité│ -│ ├── QuestContext · TutorialContext · ToastContext │ -│ └── 8 services front (stats, quêtes, auth…) │ -│ │ -│ Axios → http://192.168.x.x:4000/api │ -└──────────────────────────┬──────────────────────────┘ - │ réseau local -┌──────────────────────────▼──────────────────────────┐ -│ NODE.JS API (port 4000) │ -│ │ -│ Routes → Controllers → Services → Mongoose │ -│ ├── /api/auth (register, login, OTP, reset) │ -│ ├── /api/users (profil, RGPD) │ -│ ├── /api/workouts (CRUD, draft, finalize) │ -│ └── /api/exercises (performances, historique) │ -│ │ -│ MongoDB Atlas (cloud) Brevo SMTP │ -└─────────────────────────────────────────────────────┘ +Téléphone ou navigateur (Expo Go, PWA) + React Native / React Native Web + AuthContext (JWT via SecureStore) + UserContext, WorkoutLogsContext, QuestContext, + SavedWorkoutsContext, CustomExercisesContext, + TutorialContext, ToastContext + 17 services front, exposés via un barrel unique + + Axios -> http://votre-serveur:4000/api + +API Node.js (port 4000) + Routes -> Controllers -> Services -> Mongoose + /api/auth inscription, connexion, OTP, Google, réinitialisation + /api/users profil, cadre équipé, vitrines, synchronisation XP, RGPD + /api/workouts séances, brouillons, finalisation, anti-triche + /api/exercises performances, historique, classement + /api/friends amis, demandes, classement, profil public + /api/inventory coffres, objets, cosmétiques Uniques + /api/groups groupes de streak collective + /api/rewards trophées serveur, anniversaire + /api/referral parrainage + /api/profile titres RPG + /api/lobby lobby multijoueur + /api/activity flux d'activité et réactions + /api/weight historique de poids + /api/debug outillage God Mode, bloqué en production + + MongoDB Atlas Brevo SMTP Google OAuth (optionnel) ``` -**Répartition des responsabilités :** +### Répartition des responsabilités | Fonctionnalité | Stockage | Calcul | -|---------------|----------|--------| -| Authentification | MongoDB (back) | back | -| Profil utilisateur | MongoDB (back) | back | -| Logs de séances | AsyncStorage (front) | front | -| XP, streak, niveau | AsyncStorage (front) | front | -| Quêtes quotidiennes | AsyncStorage (front) | front | -| Rituels de récupération | AsyncStorage (front) | front | -| Sync séances | MongoDB (best-effort) | front → back | +|-----------------|----------|--------| +| Authentification | MongoDB (backend) | backend | +| Profil utilisateur | MongoDB (backend) | backend | +| Social, groupes, lobby, inventaire, titres | MongoDB (backend) | backend | +| Logs de séances | AsyncStorage (frontend) | frontend | +| XP, streak, niveau local | AsyncStorage (frontend) | frontend | +| Quêtes quotidiennes, rituels | AsyncStorage (frontend) | frontend | +| Synchronisation de l'XP totale | MongoDB, best effort | frontend calcule, backend fait autorité | --- -## Build APK (Android) +## Build (Android et PWA) -Pour générer un APK de production via EAS Build : +### APK Android via EAS Build ```bash cd front - -# Installer EAS CLI (une seule fois) npm install -g eas-cli eas login - -# Build production eas build --platform android --profile production ``` -Le profil `production` dans `eas.json` pointe vers l'API déployée sur Render (`https://athly-api.onrender.com`). Pour viser votre propre backend, modifier `API_URL` dans la section `env` du profil `production` de `eas.json`. +Le profil `production` dans `front/eas.json` pointe vers l'API déployée. Pour cibler un autre backend, modifier `API_URL` dans la section `env` de ce profil. + +### PWA web + +```bash +cd front +npm run build:web +# expo export --platform web, puis copie du service worker +``` --- ## Tests -**Back-end** +**Backend** + ```bash cd back npm test ``` -73 tests : formule XP/niveau (24), anti-cheat serveur (13), intégrité des schémas Mongoose (36). Les tests unitaires tournent sans connexion MongoDB. -**Front-end** +26 fichiers de tests, exécutés contre une instance MongoDB en mémoire, sans dépendance réseau externe. Couvre l'authentification, le profil, les séances, le social, les groupes, l'inventaire, les trophées, les titres, le lobby multijoueur et l'ensemble des formules de gamification. + +**Frontend** + ```bash cd front npm test ``` +3 suites Jest sur les modules de données et services purs. + +--- + +## Intégration continue + +Le workflow GitHub Actions (`.github/workflows/ci.yml`) exécute deux jobs indépendants, filtrés par dossier modifié : + +- Backend : lint, vérification syntaxique, audit de sécurité npm, suite de tests complète, sans base de données externe. +- Frontend : audit de sécurité npm, build de la PWA (`expo export --platform web`), pour détecter tout composant natif incompatible avec le web. + --- ## Déploiement | Composant | Plateforme | Notes | -|-----------|-----------|-------| -| Back-end | Render (Free tier) | Cold-start ~20s — absorbé par le timeout Axios 30s du front | +|-----------|------------|-------| +| Backend | Render (tier gratuit) | Démarrage à froid d'environ 20 secondes, absorbé par le timeout Axios du front | | Base de données | MongoDB Atlas M0 | Gratuit | -| Emails | Brevo SMTP | Tier gratuit : 300 emails/jour | -| App mobile | EAS Build | APK Android via `eas build --profile production` | +| Emails | Brevo SMTP | Tier gratuit à 300 emails par jour | +| Application mobile | EAS Build | APK Android via `eas build --profile production` | +| PWA | Build statique | `npm run build:web`, à héberger sur tout service de fichiers statiques | + +--- + +## Sécurité + +Le détail complet des protections (headers HTTP, rate limiting, validation, assainissement anti-injection, tolérance aux pannes) est documenté dans [docs/ARCHITECTURE-SECURITE.md](docs/ARCHITECTURE-SECURITE.md). + +--- + +## Licence + +Distribué sous licence GNU GPLv3. Voir le fichier [LICENSE](LICENSE). ---
-Athly · React Native + Expo · Node.js + Express · MongoDB Atlas +Athly : React Native + Expo, Node.js + Express, MongoDB Atlas
diff --git a/back/README.md b/back/README.md index c109bb1..554d8b6 100644 --- a/back/README.md +++ b/back/README.md @@ -1,1212 +1,759 @@
-# ⚙️ ATHLY — API Backend +# ATHLY : API Backend -**Node.js · Express · MongoDB · JWT · Nodemailer** +**Node.js, Express, MongoDB, JWT, Google OAuth, Nodemailer** -![Node.js](https://img.shields.io/badge/Node.js-22.x-339933?style=flat-square&logo=node.js) +![Node.js](https://img.shields.io/badge/Node.js-20.x-339933?style=flat-square&logo=node.js) ![Express](https://img.shields.io/badge/Express-5.2.1-000000?style=flat-square&logo=express) ![MongoDB](https://img.shields.io/badge/MongoDB-Mongoose_9-47A248?style=flat-square&logo=mongodb) -![JWT](https://img.shields.io/badge/Auth-JWT_+_OTP-orange?style=flat-square) +![JWT](https://img.shields.io/badge/Auth-JWT_+_OTP_+_Google-orange?style=flat-square) ![Jest](https://img.shields.io/badge/Tests-Jest_30-C21325?style=flat-square&logo=jest) -> API REST pour l'application mobile Athly. Gère l'authentification (JWT + OTP email), les profils utilisateurs, les séances d'entraînement et la synchronisation des performances. +API REST pour l'application mobile Athly. Gère l'authentification (mot de passe, OTP email, Google OAuth), les profils, les séances d'entraînement, ainsi que l'ensemble du système social et de gamification (amis, groupes de streak, coffres, titres, trophées, lobby multijoueur, parrainage).
--- -## 📋 Table des matières - -1. [Vue d'ensemble](#-vue-densemble) -2. [Stack technique](#-stack-technique) -3. [Installation & lancement](#-installation--lancement) -4. [Variables d'environnement](#-variables-denvironnement) -5. [Structure du projet](#-structure-du-projet) -6. [Architecture](#-architecture) -7. [Authentification](#-authentification) -8. [Documentation API](#-documentation-api) - - [Auth — `/api/auth`](#1-auth--apiauth) - - [Utilisateurs — `/api/users`](#2-utilisateurs--apiusers) - - [Séances — `/api/workouts`](#3-séances--apiworkouts) - - [Exercices — `/api/exercises`](#4-exercices--apiexercises) -9. [Modèles de données](#-modèles-de-données) -10. [Services & logique métier](#-services--logique-métier) -11. [Tests](#-tests) -12. [Intégration continue (CI)](#️-intégration-continue-ci) -13. [Sécurité](#-sécurité) +## Table des matières + +1. [Vue d'ensemble](#vue-densemble) +2. [Stack technique](#stack-technique) +3. [Installation et lancement](#installation-et-lancement) +4. [Variables d'environnement](#variables-denvironnement) +5. [Structure du projet](#structure-du-projet) +6. [Architecture](#architecture) +7. [Authentification](#authentification) +8. [Documentation de l'API](#documentation-de-lapi) +9. [Modèles de données](#modèles-de-données) +10. [Services et logique métier](#services-et-logique-métier) +11. [Catalogues de données](#catalogues-de-données) +12. [Formules de gamification](#formules-de-gamification) +13. [Tests](#tests) +14. [Intégration continue](#intégration-continue) +15. [Sécurité](#sécurité) --- -## 🎯 Vue d'ensemble +## Vue d'ensemble -L'API Athly est un backend **Node.js + Express + MongoDB** indispensable au fonctionnement de l'application mobile. L'authentification (connexion, inscription, vérification email) passe obligatoirement par ce backend. Une fois connecté, les données de progression (logs, XP, quêtes) sont calculées et stockées côté client (AsyncStorage) ; le backend reçoit les séances finalisées en best-effort pour la persistance cloud. +L'API Athly est un backend Node.js, Express et MongoDB. L'authentification (inscription, connexion, vérification email, connexion Google, réinitialisation de mot de passe) passe obligatoirement par ce backend. Une fois connecté, les données de progression immédiate (logs de séances, XP calculé, quêtes, rituels) sont calculées et stockées côté client dans AsyncStorage pour la fluidité, tandis que le backend fait autorité pour tout ce qui touche à la persistance cloud, au multijoueur et aux fonctionnalités sociales : profil, inventaire, coffres, titres, trophées, amis, groupes de streak et lobby multijoueur. ### Rôle du backend | Fonction | Description | -|----------|-------------| -| **Authentification** | Inscription, connexion JWT, vérification email OTP, reset password | -| **Profil utilisateur** | Données physiques, objectifs, équipements, XP/niveau | -| **Séances** | Création, finalisation, historique des workouts | -| **Performances** | Enregistrement des séries, historique par exercice | -| **Exercices externes** | Proxy vers l'API WGER (catalogue 1000+ exercices) | -| **Email transactionnel** | Codes OTP via SMTP Yahoo (Nodemailer) | - ---- - -## 🛠 Stack technique +|----------|--------------| +| Authentification | Inscription, connexion par mot de passe, connexion Google OAuth, vérification email par OTP, réinitialisation de mot de passe | +| Profil utilisateur | Données physiques, objectifs, équipements, XP et niveau, cadre de profil équipé, RGPD | +| Séances | Création, brouillon, finalisation, historique, anti-triche serveur | +| Performances | Enregistrement des séries par exercice, historique de progression, classement par exercice | +| Synchronisation XP | Réception de l'XP totale calculée côté client, recalcul du niveau et du rang, ratchet anti-régression | +| Inventaire et coffres | Ouverture de coffres, utilisation d'objets, réclamation de cosmétiques Uniques | +| Amis et classement | Demandes d'amis, liste, recherche par tag, classement XP, profil public | +| Groupes de streak | Groupe de 5 membres maximum, invitations, streak collective, action Secouer | +| Titres RPG | Catalogue de 17 titres déblocables, équipement | +| Trophées | Catalogue serveur, synchronisation des trophées locaux, anniversaire, parrainage | +| Lobby multijoueur | Création de lobby, invitations, statut prêt, bonus d'XP de groupe | +| Flux d'activité | Réactions entre amis (bravo, respect, hue, jaloux) sur des événements marquants | +| Poids | Historique de pesées | +| Email transactionnel | Codes OTP et notifications via SMTP (Brevo) | +| Notifications push | Enregistrement du token Expo, envoi via expo-server-sdk | +| Outillage de test (God Mode) | Endpoints réservés au développement pour simuler des états de jeu | + +--- + +## Stack technique | Couche | Technologie | Version | |--------|-------------|---------| -| Runtime | Node.js | ≥ 18 | +| Runtime | Node.js | 20.x (CI), 18+ en local | | Framework HTTP | Express | 5.2.1 | -| ODM | Mongoose | 9.0.0 | -| Base de données | MongoDB Atlas | — | -| Authentification | JSON Web Token | 9.0.3 | -| Hash passwords | bcrypt | 6.0.0 | -| Email | Nodemailer (SMTP) | 8.0.10 | -| Validation | Joi | 18.0.2 | +| ODM | Mongoose | 9.7.3 | +| Base de données | MongoDB Atlas (production), mongodb-memory-server (tests) | | +| Authentification par mot de passe | JSON Web Token | 9.0.3 | +| Authentification sociale | google-auth-library | 10.x | +| Hash des mots de passe | bcrypt | 6.0.0 | +| Email | Nodemailer (SMTP) | 9.0.3 | +| Notifications push | expo-server-sdk | 6.x | +| Validation | Joi | 18.2.3 | | Sécurité HTTP | Helmet | 8.0.0 | +| Rate limiting | express-rate-limit | 8.x | | CORS | cors | 2.8.5 | | Logging | Morgan | 1.10.0 | -| Variables d'env | dotenv | 17.2.3 | -| Tests | Jest + Supertest | 30.2.0 | +| Variables d'environnement | dotenv | 17.2.3 | +| Tests | Jest, Supertest, mongodb-memory-server | 30.4.2 | | Dev | Nodemon | 3.1.11 | --- -## 🚀 Installation & lancement +## Installation et lancement ### Prérequis -- Node.js ≥ 18 -- Un cluster MongoDB (local ou Atlas) -- Un compte SMTP pour l'envoi d'emails (Yahoo, Gmail, etc.) +- Node.js 18 ou supérieur +- Un cluster MongoDB (Atlas ou local) pour la production. Les tests n'en nécessitent pas : ils démarrent leur propre instance en mémoire. +- Un compte SMTP pour l'envoi d'emails (Brevo recommandé, tier gratuit à 300 emails par jour) +- Optionnel : des Client IDs Google OAuth si la connexion Google doit être active ### Étapes ```bash -# 1. Cloner le dépôt git clone https://github.com/ClemLy/Athly.git cd Athly/back -# 2. Installer les dépendances npm install -# 3. Configurer les variables d'environnement cp .env.example .env -# Éditer .env (voir section suivante) - -# 4. Lancer en développement (avec hot-reload) -npm run dev +# Éditer .env, voir la section Variables d'environnement ci-dessous -# 5. Lancer en production -npm start +npm run dev # développement, avec rechargement à chaud (nodemon) +npm start # production ``` ### Vérifier que le serveur tourne ```bash curl http://localhost:4000/health -# → {"status":"OK","uptime":42.3} +# {"status":"OK","uptime":42.3} ``` --- -## 🔑 Variables d'environnement +## Variables d'environnement -Créer un fichier `.env` à la racine du projet : +Toutes les variables sont documentées avec leur usage exact dans `.env.example`. Résumé : ```env # Serveur PORT=4000 NODE_ENV=development -# MongoDB +# MongoDB (obligatoire au démarrage) MONGO_URI=mongodb+srv://:@.mongodb.net/?retryWrites=true&w=majority -# JWT -JWT_SECRET=votre_secret_jwt_tres_long_et_aleatoire +# JWT (obligatoire au démarrage) +JWT_SECRET=secret_long_et_aleatoire JWT_EXPIRES_IN=1d -# Email SMTP (exemple avec Yahoo) -SMTP_HOST=smtp.mail.yahoo.com -SMTP_PORT=465 -SMTP_USER=votre.adresse@yahoo.fr -SMTP_PASS=votre_app_password_yahoo -``` - -> **Important :** Ne jamais committer le `.env` — il est dans `.gitignore`. Les credentials SMTP doivent être des **app passwords** (pas votre mot de passe principal). - ---- - -## 📁 Structure du projet - -``` -back-projet-final-ClemLy/ -├── server.js # Point d'entrée : listen() -├── app.js # Config Express, routes, middlewares -├── package.json -├── jest.config.js # Configuration Jest -├── eslint.config.mjs # Configuration ESLint -│ -├── config/ -│ ├── db.js # Connexion Mongoose (connectDB) -│ └── env.js # Validation et export des variables d'env -│ -├── controllers/ # Handlers HTTP (entrée/sortie) -│ ├── auth.controller.js # register, login, verifyEmail, forgotPassword… -│ ├── user.controller.js # getMe, updateMe, deleteAccount -│ ├── workout.controller.js # CRUD séances, draft, finalize, complete -│ └── exercise.controller.js # createRecord, getHistory, getByWorkout -│ -├── services/ # Logique métier (pur, testable) -│ ├── auth.service.js # Création compte, OTP, JWT -│ ├── user.service.js # Profil, XP/level, suppression cascade -│ ├── workout.service.js # Calculs volume, XP séance, draft/finalize -│ ├── exercise.service.js # Records, historique progressions -│ ├── email.service.js # Templates HTML + envoi SMTP -│ └── wger.service.js # Proxy API WGER (exercices externes) -│ -├── models/ # Schémas Mongoose -│ ├── User.js -│ ├── Workout.js -│ └── ExerciseRecord.js -│ -├── routes/ # Déclaration des routes -│ ├── auth.routes.js -│ ├── user.routes.js -│ ├── workout.routes.js -│ └── exercise.routes.js -│ -├── middleware/ -│ ├── auth.middleware.js # Vérification JWT (protect) -│ ├── validate.middleware.js # Validation Joi (validateBody) -│ ├── error.middleware.js # Gestionnaire d'erreurs global -│ └── not-found.middleware.js # 404 handler -│ -├── validators/ # Schémas Joi -│ ├── auth.validator.js -│ ├── user.validator.js -│ ├── workout.validator.js -│ └── exercise.validator.js -│ -├── tests/ -│ ├── levelHelpers.test.js # Formule XP/niveau — 24 tests (unitaires) -│ ├── workoutAnticheat.test.js # Anti-cheat serveur — 13 tests (mocks Mongoose) -│ ├── modelsIntegrity.test.js # Intégrité des schémas Mongoose — 36 tests -│ ├── auth.test.js -│ ├── user.test.js -│ ├── workout.test.js -│ ├── exercise.test.js -│ └── health.test.js -│ -├── utils/ -│ └── levelHelpers.js # Source de vérité XP/niveau (xpForLevel, levelFromXP) -│ -└── scripts/ - └── populateWorkouts.js # Seed de données de test -``` - ---- - -## 🏗 Architecture +# Refresh token (optionnel, non utilisé par l'implémentation actuelle) +REFRESH_TOKEN_SECRET= +REFRESH_TOKEN_EXPIRES_IN=7d + +# Email SMTP (exemple Brevo) +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_USER=votre_login_brevo +SMTP_PASS=votre_cle_api_brevo +SMTP_FROM=noreply@votre-domaine.com + +# Google OAuth (optionnel : sans valeur, POST /api/auth/google répond 501) +# Liste séparée par des virgules : un Client ID par plateforme front +# (iOS, Android, Web, Expo Go), car le claim "aud" du token contient +# exactement celui qui l'a émis. +GOOGLE_CLIENT_IDS= + +# CORS (production uniquement) +# Allowlist des origines web autorisées, séparées par des virgules. +# Les requêtes sans header Origin (app mobile native) ne sont pas concernées. +# Non définie : comportement permissif en développement, avertissement en production. +CORS_ORIGINS= +``` + +Ne jamais committer le fichier `.env` : il est dans `.gitignore`. Les identifiants SMTP doivent être des clés d'application dédiées, jamais un mot de passe de compte personnel. + +--- + +## Structure du projet + +``` +back/ + server.js Point d'entrée : connexion DB puis listen() + app.js Configuration Express : middlewares, montage des routes + eslint.config.mjs + jest.config.js + + config/ + db.js Connexion Mongoose (connectDB) + env.js Lecture et validation des variables d'environnement + + models/ 8 schémas Mongoose + User.js + Workout.js + ExerciseRecord.js + Friendship.js + StreakGroup.js + WorkoutLobby.js + ActivityEvent.js + WeightHistory.js + + controllers/ 14 fichiers, handlers HTTP + auth.controller.js + user.controller.js + workout.controller.js + exercise.controller.js + friend.controller.js + inventory.controller.js + groupStreak.controller.js + reward.controller.js + referral.controller.js + title.controller.js + workoutLobby.controller.js + weight.controller.js + activity.controller.js + debug.controller.js + + services/ 9 fichiers, logique métier pure et testable + auth.service.js + user.service.js + workout.service.js + exercise.service.js + email.service.js + push.service.js + chest.service.js + inventory.service.js + activity.service.js + + routes/ 14 fichiers, déclaration des endpoints + validators/ 7 fichiers, schémas Joi + middleware/ + auth.middleware.js Vérification JWT (protect) + devOnly.middleware.js Bloque une route en production (404) + validate.middleware.js Validation Joi du corps de requête + sanitize.middleware.js Assainissement anti-injection NoSQL + rateLimit.middleware.js Limiteurs global et authentification + error.middleware.js Gestionnaire d'erreurs global + not-found.middleware.js 404 handler + + data/ + titleCatalog.js 17 titres RPG déblocables + localTrophyCatalog.js Miroir des 40 trophées locaux du front, plus le trophée Souverain Absolu + shakeMessages.js Messages aléatoires du bouton Secouer + + utils/ + levelHelpers.js Source de vérité XP et niveau (xpForLevel, levelFromXP, getRankForLevel) + profanityFilter.js Filtre de pseudos + + tests/ 26 fichiers de tests, voir la section Tests + scripts/ + populateWorkouts.js Seed de données de test + jest-staged.js Utilisé par lint-staged +``` + +--- + +## Architecture ### Flux d'une requête ``` -Client (React Native) - │ - ▼ +Client (React Native / PWA) + | + v Express Router - │ - ├─→ Middleware auth.middleware (JWT protect) - │ └─ Vérifie Bearer token → req.user = payload - │ - ├─→ Middleware validate.middleware (Joi) - │ └─ Valide req.body contre le schéma - │ - ▼ + | + +--> Middleware auth.middleware (JWT protect) + | Vérifie le Bearer token, injecte req.user + | + +--> Middleware validate.middleware (Joi) + | Valide req.body contre le schéma de la route + | + v Controller - │ Handler HTTP : valide entrées, appelle service, retourne réponse - ▼ + | Handler HTTP : lit la requête, appelle le service, formate la réponse + v Service - │ Logique métier pure, indépendante d'Express - ▼ + | Logique métier pure, indépendante d'Express + v Model (Mongoose) - │ - ▼ - MongoDB Atlas + | + v + MongoDB ``` +Séparation stricte : les routes ne font que déclarer method plus path plus middleware plus controller. Les controllers ne contiennent aucune requête Mongoose directe pour la logique complexe (délégation au service), à l'exception de quelques controllers qui restent volontairement compacts pour des opérations simples de lecture ou d'écriture directe (par exemple `activity.controller.js`, `weight.controller.js`). Les services ne connaissent jamais `req` ou `res`. + ### Gestion des erreurs -Toutes les erreurs non gérées remontent au middleware `error.middleware.js` qui normalise le format : +Toutes les erreurs non gérées remontent au middleware `error.middleware.js`, qui normalise la réponse : ```json { - "status": "error", + "success": false, "message": "Description de l'erreur", - "code": 400 + "status": 400 } ``` +### Résilience + +- `unhandledRejection` est logué sans tuer le process. +- `uncaughtException` déclenche un arrêt propre (log puis exit, l'orchestrateur redémarre). +- Arrêt gracieux sur SIGTERM et SIGINT : drain des requêtes en cours, fermeture de la connexion MongoDB, garde-fou de 10 secondes. +- MongoDB se reconnecte automatiquement en cas de coupure (Mongoose), les événements sont logués. +- Les opérations sensibles à la concurrence (par exemple la consommation du dernier objet d'inventaire) utilisent des mises à jour atomiques (`findOneAndUpdate` conditionnel avec `$inc`), pas de lecture puis écriture. + +Voir `docs/ARCHITECTURE-SECURITE.md` à la racine du dépôt pour le détail complet des protections et de la tolérance aux pannes. + --- -## 🔐 Authentification +## Authentification -### Flux complet d'inscription +### Inscription par email ``` POST /api/auth/register - └─ Crée le compte (isVerified: false) - └─ Génère un code OTP 6 chiffres (valide 10 min) - └─ Envoie un email avec le code - │ - ▼ + Crée le compte (isVerified: false) + Génère un code OTP à 6 chiffres, valide 10 minutes + Envoie un email avec le code + Génère un code de parrainage et un discriminant à 4 chiffres + POST /api/auth/verify-email - └─ Vérifie le code OTP - └─ isVerified → true - └─ Retourne JWT token (connexion automatique) + Vérifie le code OTP + isVerified passe à true + Retourne le token JWT (connexion automatique) ``` -### Flux de reset password +### Connexion Google ``` -POST /api/auth/forgot-password - └─ Génère un code reset (valide 15 min) - └─ Envoie l'email - │ - ▼ -POST /api/auth/reset-password - └─ Vérifie le code - └─ Hash le nouveau password (bcrypt) - └─ Invalide le code +POST /api/auth/google + Vérifie l'idToken auprès de Google (audience parmi GOOGLE_CLIENT_IDS) + Crée le compte au premier login (isVerified: true d'office, email garanti par Google) + Retourne le token JWT ``` -### Protection brute-force - -- Maximum **5 tentatives** de vérification OTP avant blocage -- Les codes OTP expirent automatiquement (10 min vérif, 15 min reset) -- Mongoose TTL index sur `codeExpires` - -### Utiliser le token JWT +Sans `GOOGLE_CLIENT_IDS` configuré côté serveur, la route répond 501 plutôt que de faire planter le démarrage du serveur. -Toutes les routes protégées nécessitent : +### Réinitialisation de mot de passe ``` -Authorization: Bearer -``` - -Le token expire selon `JWT_EXPIRES_IN` (défaut : `1d`). - ---- - -## 📖 Documentation API - -**Base URL :** `http://votre-serveur:4000/api` - ---- +POST /api/auth/forgot-password + Génère un code de réinitialisation, valide 15 minutes + Envoie l'email + Répond toujours 200, même si l'email n'existe pas (ne révèle jamais l'existence d'un compte) -### 1. Auth — `/api/auth` +POST /api/auth/reset-password + Vérifie le code + Hash le nouveau mot de passe + Invalide le code +``` -Toutes ces routes sont **publiques** (pas de JWT requis). +### Protection contre le brute-force ---- +- Maximum 5 tentatives de vérification OTP avant blocage (nécessite un nouveau code). +- Les codes OTP expirent automatiquement (10 minutes pour la vérification, 15 minutes pour la réinitialisation). +- Le rate limiter d'authentification limite à 20 requêtes par 15 minutes, avec une clé combinant IP et email ciblé. -#### `POST /api/auth/register` - -Créer un nouveau compte utilisateur. +### Utiliser le token JWT -**Body** -```json -{ - "pseudo": "AthlèteExemple", - "email": "athlete@mail.com", - "password": "motdepasse123" -} ``` - -**Réponse 201** -```json -{ - "message": "Compte créé. Vérifiez votre email.", - "email": "athlete@mail.com" -} +Authorization: Bearer ``` -**Erreurs** -| Code | Cause | -|------|-------| -| 400 | Email déjà utilisé | -| 422 | Validation Joi échouée (email invalide, password trop court) | +Expiration selon `JWT_EXPIRES_IN` (défaut 1 jour). Algorithme épinglé HS256, jamais de log du token ou des headers d'authentification. --- -#### `POST /api/auth/login` +## Documentation de l'API -Connexion et récupération du token JWT. +URL de base : `http://votre-serveur:4000/api` -**Body** -```json -{ - "email": "athlete@mail.com", - "password": "motdepasse123" -} -``` - -**Réponse 200** -```json -{ - "message": "Connexion réussie.", - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "user": { - "_id": "64f2a...", - "pseudo": "AthlèteExemple", - "email": "athlete@mail.com", - "isVerified": true, - "xp": 2400, - "level": 8 - } -} -``` +Sauf mention contraire, toutes les routes ci-dessous nécessitent `Authorization: Bearer `. -**Erreurs** -| Code | Cause | -|------|-------| -| 401 | Mauvais email ou password | -| 403 | Email non vérifié | +### 1. Authentification : `/api/auth` (public) ---- - -#### `POST /api/auth/verify-email` +| Méthode | Route | Description | +|---------|-------|--------------| +| POST | `/register` | Créer un compte, envoie un OTP de vérification | +| POST | `/login` | Connexion par email et mot de passe | +| POST | `/google` | Connexion ou création de compte via Google OAuth | +| POST | `/verify-email` | Valider le code OTP reçu par email | +| POST | `/resend-verification` | Renvoyer un nouveau code de vérification | +| POST | `/forgot-password` | Demander un code de réinitialisation | +| POST | `/reset-password` | Réinitialiser le mot de passe avec le code reçu | -Vérifier son email avec le code OTP reçu. +Exemple, connexion réussie : -**Body** ```json -{ - "email": "athlete@mail.com", - "code": "847291" -} -``` +POST /api/auth/login +{ "email": "athlete@mail.com", "password": "motdepasse123" } -**Réponse 200** -```json +200 OK { - "message": "Email vérifié avec succès.", + "message": "Connexion réussie.", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "user": { ... } + "user": { "_id": "...", "pseudo": "Athlete", "xp": 2400, "level": 8, "rank": "Initié" } } ``` -**Erreurs** -| Code | Cause | -|------|-------| -| 400 | Code incorrect ou expiré | -| 429 | Trop de tentatives (> 5) | - ---- - -#### `POST /api/auth/resend-verification` - -Renvoyer un nouveau code de vérification. - -**Body** -```json -{ "email": "athlete@mail.com" } -``` - -**Réponse 200** -```json -{ "message": "Code renvoyé." } -``` +### 2. Utilisateurs : `/api/users` ---- - -#### `POST /api/auth/forgot-password` +| Méthode | Route | Description | +|---------|-------|--------------| +| GET | `/me` | Profil complet de l'utilisateur connecté | +| PUT | `/me` | Mettre à jour le profil (champs optionnels) | +| PUT | `/me/frame` | Mettre à jour le cadre de profil équipé (forme et couleur) | +| PUT | `/me/showcase` | Mettre à jour la vitrine de trophées mis en avant (3 maximum) | +| PUT | `/me/records-showcase` | Mettre à jour les records d'exercices mis en avant (6 maximum) | +| PUT | `/me/push-token` | Enregistrer ou effacer le token de notification push Expo | +| POST | `/me/complete-onboarding` | Marquer le tutoriel interactif comme terminé (idempotent) | +| POST | `/me/sync-xp` | Synchroniser l'XP totale calculée côté client vers le serveur | +| DELETE | `/delete-account` | Supprimer définitivement le compte et toutes ses données (RGPD) | -Demander un code de reset de mot de passe. +`POST /me/sync-xp` est le point d'entrée qui fait du serveur la source de vérité pour tout ce qui est contrôlé côté backend (déblocage de coffres au niveau 11, conditions de titres). Il applique un ratchet : l'XP ne redescend jamais, un envoi tardif ou redondant est toujours sans danger. -**Body** ```json -{ "email": "athlete@mail.com" } -``` +POST /api/users/me/sync-xp +{ "xp": 15420 } -**Réponse 200** -```json -{ "message": "Code de réinitialisation envoyé par email." } +200 OK +{ "success": true, "level": 22, "xp": 15420, "rank": "Initié", "newlyUnlockedTitles": ["PERFORM_LEVEL_20"] } ``` -> **Note :** Renvoie toujours 200 même si l'email n'existe pas (sécurité — ne révèle pas si un compte existe). +Suppression de compte : cascade `ExerciseRecord` puis `Workout` puis `User`. ---- +### 3. Séances : `/api/workouts` -#### `POST /api/auth/reset-password` +| Méthode | Route | Description | +|---------|-------|--------------| +| POST | `/` | Créer et enregistrer une séance déjà terminée | +| GET | `/` | Lister toutes les séances de l'utilisateur, triées par date décroissante | +| GET | `/:id` | Détail complet d'une séance | +| DELETE | `/:id` | Supprimer une séance | +| POST | `/draft` | Créer un brouillon de séance | +| PATCH | `/:id/draft` | Mettre à jour un brouillon (auto-save pendant l'entraînement) | +| POST | `/:id/finalize` | Finaliser une séance : calcule les totaux et applique l'anti-triche serveur | +| POST | `/:id/complete` | Marquer une séance comme complétée (variante allégée de finalize) | -Réinitialiser le mot de passe avec le code reçu. +Anti-triche temporel, appliqué côté serveur en miroir du calcul client : -**Body** -```json -{ - "email": "athlete@mail.com", - "code": "391847", - "newPassword": "nouveauMotDePasse456" -} -``` - -**Réponse 200** -```json -{ "message": "Mot de passe réinitialisé avec succès." } +```javascript +if (shortSession === true || duration < 300) xp = 0 // moins de 5 minutes +else if (duration < 900) xp = round(xp / 10) // 5 à 15 minutes +// 15 minutes ou plus : XP plein ``` ---- - -### 2. Utilisateurs — `/api/users` - -Toutes ces routes nécessitent `Authorization: Bearer `. - ---- +### 4. Exercices : `/api/exercises` -#### `GET /api/users/me` +| Méthode | Route | Description | +|---------|-------|--------------| +| POST | `/` | Enregistrer les performances d'un exercice pour une séance | +| GET | `/leaderboard` | Classement des amis sur un exercice donné | +| GET | `/my-records` | Tous mes records personnels | +| GET | `/history/:name` | Historique de progression d'un exercice (pour les graphes) | +| GET | `/workout/:workoutId` | Tous les records associés à une séance | -Récupérer le profil de l'utilisateur connecté. +### 5. Amis : `/api/friends` -**Réponse 200** -```json -{ - "user": { - "_id": "64f2a...", - "pseudo": "AthlèteExemple", - "email": "athlete@mail.com", - "age": 25, - "sexe": "H", - "poids": 80, - "poidsCible": 75, - "taille": 180, - "niveauSportif": "Intermédiaire", - "objectif": "prise de masse", - "rythme": 4, - "equipements": ["Haltères", "Barre", "Poulie"], - "xp": 2400, - "level": 8, - "createdAt": "2024-01-15T10:30:00.000Z" - } -} -``` +| Méthode | Route | Description | +|---------|-------|--------------| +| POST | `/request` | Envoyer une demande d'ami | +| PUT | `/accept/:requestId` | Accepter une demande reçue | +| PUT | `/decline/:requestId` | Refuser une demande reçue | +| DELETE | `/request/:requestId` | Annuler une demande envoyée | +| DELETE | `/:friendshipId` | Retirer un ami | +| GET | `/list` | Liste de tous mes amis acceptés | +| GET | `/pending` | Demandes reçues en attente | +| GET | `/search` | Rechercher un utilisateur par tag exact (Pseudo suivi de son discriminant) | +| GET | `/leaderboard` | Classement XP entre amis | +| GET | `/profile/:friendId` | Profil public d'un ami | ---- +Chaque amitié possède son propre niveau (1 à 5), qui progresse avec l'XP d'interactions partagées (paliers à 0, 100, 300, 700 et 1500). -#### `PUT /api/users/me` +### 6. Inventaire : `/api/inventory` -Mettre à jour le profil utilisateur. +| Méthode | Route | Description | +|---------|-------|--------------| +| POST | `/chest/open` | Ouvrir un coffre (consomme une CHEST_KEY) | +| POST | `/item/use` | Utiliser un objet consommable | +| POST | `/claim` | Réclamer un cosmétique Unique débloqué | -**Body** (tous les champs sont optionnels) -```json -{ - "pseudo": "NouveauPseudo", - "age": 26, - "poids": 78, - "poidsCible": 72, - "taille": 180, - "niveauSportif": "Avancé", - "objectif": "force", - "rythme": 5, - "equipements": ["Haltères", "Barre", "Anneaux"] -} -``` +Un coffre s'obtient toutes les 2 heures de séance cumulées, débloqué à partir du rang Initié (niveau 11). Table de drop : commun 60 pour cent, rare 25 pour cent, épique 12 pour cent, légendaire 3 pour cent. -**Réponse 200** -```json -{ - "message": "Profil mis à jour.", - "user": { ... } -} -``` +### 7. Groupes de streak : `/api/groups` ---- +| Méthode | Route | Description | +|---------|-------|--------------| +| GET | `/my-group` | Mon groupe actuel, invitations en attente | +| POST | `/leave` | Quitter le groupe | +| POST | `/invite` | Inviter un ami dans le groupe | +| PUT | `/respond/:groupId` | Accepter ou refuser une invitation | +| POST | `/:groupId/shake/:memberId` | Secouer un membre en retard (notification push) | +| POST | `/:groupId/check-streak` | Vérifier et mettre à jour la streak collective du jour | -#### `DELETE /api/users/delete-account` +Un groupe compte 5 membres maximum. La streak collective s'incrémente si tous les membres valident leur journée. Une streak de groupe de 30 jours à taille maximale débloque, une seule fois, le cosmétique Unique Rouge Sang pour chaque membre. -Supprimer définitivement le compte et toutes ses données (RGPD). +### 8. Récompenses : `/api/rewards` -> **Suppression en cascade :** ExerciseRecord → Workout → User +| Méthode | Route | Description | +|---------|-------|--------------| +| POST | `/birthdate` | Enregistrer la date de naissance (verrouillée après la première saisie) | +| POST | `/birthday/check` | Vérifier si c'est l'anniversaire du jour et distribuer la récompense | +| GET | `/achievements` | Trophées débloqués côté serveur (catalogue de 20 entrées) | +| PUT | `/achievements/sync` | Synchroniser les trophées débloqués localement côté client | +| POST | `/check` | Forcer une réévaluation des conditions de trophées | -**Réponse 200** -```json -{ "message": "Compte supprimé avec succès." } -``` +### 9. Parrainage : `/api/referral` ---- +| Méthode | Route | Description | +|---------|-------|--------------| +| POST | `/claim` | Valider un code de parrainage reçu | -### 3. Séances — `/api/workouts` +Récompense le filleul et le parrain d'un Gel de Streak et d'un Coupon de niveau chacun. Impossible d'utiliser son propre code ou un compte déjà parrainé. -Toutes ces routes nécessitent `Authorization: Bearer `. +### 10. Titres RPG : `/api/profile` ---- +| Méthode | Route | Description | +|---------|-------|--------------| +| GET | `/titles` | Mes titres débloqués et le titre actuellement équipé | +| POST | `/equip-title` | Équiper un titre parmi ceux débloqués | -#### `POST /api/workouts` +Catalogue de 17 titres, déblocables par des conditions variées (niveau, records, séances en groupe, entraide). -Créer et enregistrer une séance. +### 11. Lobby multijoueur : `/api/lobby` -**Body** -```json -{ - "name": "Push Day — Pectoraux", - "exercises": [ - { - "name": "Développé couché", - "targetMuscle": "pectoraux", - "equipment": ["Barre", "Banc"], - "sets": [ - { "weight": 80, "reps": 8, "completed": true }, - { "weight": 80, "reps": 7, "completed": true }, - { "weight": 75, "reps": 8, "completed": true } - ] - }, - { - "name": "Pompes inclinées", - "targetMuscle": "pectoraux", - "equipment": [], - "sets": [ - { "weight": 0, "reps": 15, "completed": true } - ] - } - ], - "durationSeconds": 3240, - "notes": "Bonne séance, PR sur le développé couché !" -} -``` +| Méthode | Route | Description | +|---------|-------|--------------| +| POST | `/create` | Créer un lobby | +| GET | `/:id` | État du lobby | +| POST | `/:id/invite` | Inviter un ami | +| POST | `/:id/join` | Rejoindre un lobby existant | +| POST | `/:id/ready` | Se déclarer prêt | +| POST | `/:id/unready` | Annuler son statut prêt | +| POST | `/:id/finish` | Terminer la séance en groupe | -**Réponse 201** -```json -{ - "message": "Séance créée.", - "workout": { - "_id": "65a3b...", - "user": "64f2a...", - "name": "Push Day — Pectoraux", - "exercises": [ ... ], - "durationSeconds": 3240, - "totalVolume": 1845, - "setsCompleted": 7, - "xpEarned": 185, - "status": "finished", - "date": "2024-09-15T14:22:00.000Z" - } -} -``` +Bonus d'XP de groupe selon le nombre de participants : 2 membres 15 pour cent, 3 membres 25 pour cent, 4 membres 35 pour cent, 5 membres 50 pour cent. ---- +### 12. Flux d'activité : `/api/activity` -#### `GET /api/workouts` +| Méthode | Route | Description | +|---------|-------|--------------| +| GET | `/feed` | Événements récents des amis (record battu, coffre légendaire) | +| POST | `/:eventId/react` | Réagir à un événement (bravo, respect, hue, jaloux) | -Récupérer toutes les séances de l'utilisateur connecté (triées par date décroissante). +### 13. Poids : `/api/weight` -**Réponse 200** -```json -{ - "workouts": [ - { - "_id": "65a3b...", - "name": "Push Day", - "date": "2024-09-15T14:22:00.000Z", - "totalVolume": 1845, - "setsCompleted": 7, - "durationSeconds": 3240, - "status": "finished" - }, - ... - ], - "total": 42 -} -``` +| Méthode | Route | Description | +|---------|-------|--------------| +| GET | `/history` | Historique de pesées | +| POST | `/` | Enregistrer une nouvelle pesée | ---- +### 14. Outillage de développement : `/api/debug` (bloqué en production) -#### `GET /api/workouts/:id` +Bloc d'endpoints God Mode utilisés pour simuler des états de jeu pendant le développement et les tests manuels (synchronisation de niveau, attribution de coffres et d'objets, simulation de parrainage, d'anniversaire, de groupe, d'événements sociaux, d'invitation de lobby, attribution de tous les titres). Chaque route est protégée par `devOnly.middleware.js`, qui répond 404 dès que `NODE_ENV=production`, pour ne même pas révéler leur existence. -Récupérer le détail complet d'une séance. +### Santé -**Réponse 200** -```json -{ - "workout": { - "_id": "65a3b...", - "name": "Push Day — Pectoraux", - "exercises": [ - { - "name": "Développé couché", - "targetMuscle": "pectoraux", - "sets": [ - { "weight": 80, "reps": 8, "completed": true }, - ... - ] - } - ], - "totalVolume": 1845, - "setsCompleted": 7, - "xpEarned": 185, - "durationSeconds": 3240, - "notes": "Bonne séance !", - "status": "finished", - "date": "2024-09-15T14:22:00.000Z" - } -} ``` - -**Erreurs** -| Code | Cause | -|------|-------| -| 404 | Séance introuvable ou n'appartient pas à l'utilisateur | - ---- - -#### `DELETE /api/workouts/:id` - -Supprimer une séance. - -**Réponse 200** -```json -{ "message": "Séance supprimée." } +GET /health (public) +200 OK { "status": "OK", "uptime": 3600.42 } ``` --- -#### `POST /api/workouts/draft` - -Créer un brouillon de séance (séance non commencée). +## Modèles de données -**Body** -```json -{ - "name": "Ma prochaine séance Pull", - "exercises": [] -} -``` - -**Réponse 201** -```json -{ - "workout": { - "_id": "65a3c...", - "status": "draft", - "name": "Ma prochaine séance Pull" - } -} -``` +### User ---- +Le modèle central. Identité, vérification email, Google OAuth, profil physique, gamification (XP, niveau, rang calculé), inventaire, coffres, titres débloqués et équipé, cosmétiques Uniques, onboarding, push token, parrainage, trophées, vitrine, cadre de profil équipé. Deux index composés notables : email unique, et le combo pseudo plus discriminant unique (insensible à la casse), qui permet à deux comptes de partager le même pseudo affiché tout en restant identifiables sans ambiguïté pour l'ajout d'ami. -#### `PATCH /api/workouts/:id/draft` +### Workout -Mettre à jour un brouillon (ajouter des exercices, modifier le nom…). +Une séance : liste d'exercices avec leurs séries (poids, répétitions, complété), volume total, sets complétés, XP gagné, durée, statut (`draft`, `in_progress`, `finished`, `completed`). Porte les méthodes d'instance `computeTotals()` et `finalize(options)`. -**Body** (champs partiels) -```json -{ - "name": "Pull Day — Dos", - "exercises": [ - { "name": "Tractions", "targetMuscle": "dos" } - ] -} -``` - -**Réponse 200** -```json -{ "workout": { ... } } -``` +### ExerciseRecord ---- +Performance d'un exercice pour une séance donnée : nom, liste de séries (poids et répétitions), note, poids recommandé pour la prochaine séance. -#### `POST /api/workouts/:id/finalize` +### Friendship -Finaliser une séance en cours. Calcule les totaux et attribue de l'XP. +Relation entre deux utilisateurs (`requester`, `recipient`), statut (`pending`, `accepted`, `rejected`), XP et niveau d'amitié (1 à 5). -**Body** (optionnel) -```json -{ - "durationSeconds": 3600, - "notes": "Super séance" -} -``` +### StreakGroup -**XP calculé :** `100 + 5 × setsCompleted` +Groupe de streak collective : membres, invitations en attente, streak courante, date de dernière validation, historique des Secouer envoyés, statut d'attribution du cosmétique Rouge Sang. -**Réponse 200** -```json -{ - "message": "Séance finalisée.", - "workout": { - "_id": "65a3b...", - "status": "finished", - "totalVolume": 2140, - "setsCompleted": 18, - "xpEarned": 190 - }, - "xpGained": 190, - "newLevel": 9 -} -``` +### WorkoutLobby ---- +Lobby multijoueur : créateur, membres avec leur statut individuel (`waiting`, `ready`, `finished`), statut global du lobby (`waiting`, `active`, `completed`), pourcentage de bonus d'XP calculé selon le nombre de membres. -#### `POST /api/workouts/:id/complete` +### ActivityEvent -Compléter une séance (variante alternative de finalisation). +Événement du flux d'activité social (record battu, coffre légendaire ouvert) avec ses réactions (`bravo`, `respect`, `boo`, `jealous`) par ami. -**XP calculé :** `100 + 10 × exercisesWithCompletedSets` +### WeightHistory -**Réponse 200** -```json -{ - "message": "Séance complétée.", - "xpGained": 140, - "newLevel": 9 -} -``` +Historique de pesées : poids et date, un enregistrement par entrée. --- -#### `GET /api/workouts/exercises` +## Services et logique métier -Récupérer la liste d'exercices depuis l'API WGER, filtrée par muscle et équipement. +### auth.service.js -**Query params** -``` -?muscleId=10&equipmentId=3&includeDetails=true -``` +Inscription, connexion par mot de passe, connexion Google, vérification email, renvoi de code, mot de passe oublié, réinitialisation. Génère également les codes de parrainage et discriminants uniques utilisés par `user.service.js`. -**Réponse 200** -```json -{ - "exercises": [ - { - "id": 192, - "name": "Bench Press", - "videoUrl": "https://wger.de/en/exercise/192/view/bench-press" - }, - ... - ] -} -``` - ---- +### user.service.js -### 4. Exercices — `/api/exercises` +Lecture et mise à jour du profil, mise à jour du cadre équipé, de la vitrine de trophées et de records, enregistrement du push token, marquage de l'onboarding, synchronisation de l'XP avec ratchet anti-régression, suppression de compte en cascade. -Toutes ces routes nécessitent `Authorization: Bearer `. +### workout.service.js ---- +Création, lecture, suppression de séances, gestion des brouillons, finalisation avec calcul des totaux et application de l'anti-triche temporel serveur. -#### `POST /api/exercises` +### exercise.service.js -Enregistrer les performances d'un exercice pour une séance donnée. +Enregistrement des performances, historique de progression par exercice, classement entre amis sur un exercice donné. -**Body** -```json -{ - "workout": "65a3b...", - "exerciceNom": "Développé couché", - "series": [ - { "poids": 80, "repetitions": 8 }, - { "poids": 82.5, "repetitions": 6 }, - { "poids": 80, "repetitions": 7 } - ], - "note": "Léger PR sur la série 2" -} -``` - -**Réponse 201** -```json -{ - "record": { - "_id": "65b1c...", - "exerciceNom": "Développé couché", - "series": [ ... ], - "recommandedNextWeight": 85, - "createdAt": "2024-09-15T14:22:00.000Z" - } -} -``` +### email.service.js ---- +Templates HTML de la marque (fond sombre, logo Athly) pour les codes de vérification et de réinitialisation, envoyés via Nodemailer. -#### `GET /api/exercises/history/:name` +### push.service.js -Récupérer l'historique de progression d'un exercice (pour les graphes). +Envoi de notifications push via expo-server-sdk, utilisé pour Secouer, les invitations de groupe et de lobby, les réactions du flux d'activité. -**Exemple :** `GET /api/exercises/history/Développé%20couché` +### chest.service.js -**Réponse 200** -```json -{ - "history": [ - { - "_id": "65b1c...", - "workout": "65a3b...", - "series": [ - { "poids": 80, "repetitions": 8 } - ], - "createdAt": "2024-09-15T14:22:00.000Z" - }, - { - "_id": "65b0a...", - "workout": "65a2d...", - "series": [ - { "poids": 77.5, "repetitions": 8 } - ], - "createdAt": "2024-09-12T09:15:00.000Z" - } - ] -} -``` +Table de drop pondérée et tirage aléatoire d'un objet à l'ouverture d'un coffre. ---- +### inventory.service.js -#### `GET /api/exercises/workout/:workoutId` +Logique de consommation atomique des objets d'inventaire, pour éviter toute condition de course sur la dernière unité disponible. -Récupérer tous les records d'exercices associés à une séance spécifique. +### activity.service.js -**Réponse 200** -```json -{ - "records": [ - { - "_id": "65b1c...", - "exerciceNom": "Développé couché", - "series": [ ... ] - }, - { - "_id": "65b1d...", - "exerciceNom": "Dips", - "series": [ ... ] - } - ] -} -``` +Construction et filtrage du flux d'activité social visible par un utilisateur. --- -#### `GET /health` +## Catalogues de données -Point de terminaison de santé (public, sans auth). +### `data/titleCatalog.js` -**Réponse 200** -```json -{ - "status": "OK", - "uptime": 3600.42 -} -``` +17 titres RPG déblocables, avec leurs conditions (niveau, records personnels, séances en groupe, participation communautaire). ---- +### `data/localTrophyCatalog.js` -## 🗄 Modèles de données +Miroir des métadonnées d'affichage des 40 trophées locaux définis côté front, plus le trophée capstone Souverain Absolu qui se débloque automatiquement une fois tous les autres trophées obtenus. Les conditions de déblocage sont évaluées côté client (elles dépendent des logs de séances stockés en AsyncStorage) : ce fichier ne porte que les métadonnées et sert d'allowlist pour la synchronisation. -### User +### `data/shakeMessages.js` -```javascript -{ - // Identité - pseudo: String (required, trim), - email: String (required, unique, lowercase), - password: String (required, bcrypt hash), - - // Vérification email - isVerified: Boolean (default: false), - verificationCode: String, - verifyAttempts: Number (default: 0, max: 5), - - // Reset password - resetPasswordCode: String, - codeExpires: Date, // TTL : 10 min (vérif) / 15 min (reset) - - // Profil physique - age: Number, - sexe: Enum ["H", "F", "Autre"], - poids: Number, // kg - poidsCible: Number, // kg - taille: Number, // cm - niveauSportif: Enum ["Débutant", "Intermédiaire", "Avancé"], - objectif: Enum ["prise de masse", "perte de poids", "entretien", "force"], - rythme: Number, // 1-7 séances/semaine - equipements: [String], - - // Gamification - xp: Number (default: 0), - level: Number (default: 1), - - // Timestamps Mongoose - createdAt, updatedAt -} -``` +Messages aléatoires envoyés par notification push lors d'un Secouer entre membres de groupe. -### Workout +### Catalogue de trophées serveur -```javascript -{ - user: ObjectId → User (required), - date: Date (default: Date.now), - name: String (default: "Séance"), - - exercises: [ - { - exerciseId: ObjectId → Exercise (optional), - name: String (required), - targetMuscle: String, - equipment: [String], - sets: [ - { - weight: Number, - reps: Number, - completed: Boolean (default: false), - timestamp: Date - } - ], - notes: String, - videoUrl: String - } - ], - - // Métriques (calculées à la finalisation) - durationSeconds: Number, - totalVolume: Number, // kg · reps cumulé - setsCompleted: Number, - xpEarned: Number, - notes: String, - - // Statut - status: Enum ["draft", "in_progress", "finished", "completed"], - completedAt: Date, - - // Timestamps Mongoose - createdAt, updatedAt -} - -// Méthodes d'instance -workout.computeTotals() // → {totalVolume, setsCompleted} -workout.finalize(options) // → {totalVolume, setsCompleted, xp} -``` - -### ExerciseRecord - -```javascript -{ - user: ObjectId → User (required), - workout: ObjectId → Workout (required), - - exerciceNom: String (required), - series: [ - { - poids: Number, // kg (0 si bodyweight) - repetitions: Number - } - ], - note: String, - recommandedNextWeight: Number, // suggestion poids prochaine séance - - // Timestamps Mongoose - createdAt, updatedAt -} -``` +Défini directement dans `reward.controller.js` (`ACHIEVEMENT_CATALOG`), 20 entrées réparties en trois catégories : profil (anniversaire, parrainage), social (amitié, groupe), collection (raretés d'objets et paliers de coffres ouverts). --- -## 🧠 Services & logique métier - -### auth.service.js - -| Fonction | Description | -|----------|-------------| -| `register(pseudo, email, password)` | Hash password, crée User, génère OTP 6 chiffres, envoie email | -| `login(email, password)` | Vérifie identifiants, génère JWT, retourne user | -| `verifyEmail(email, code)` | Vérifie OTP, active compte, retourne JWT | -| `resendVerification(email)` | Génère nouveau code, envoie email | -| `forgotPassword(email)` | Génère code reset, envoie email | -| `resetPassword(email, code, newPassword)` | Vérifie code, hash nouveau password, invalide code | - -**Constantes :** -```javascript -MAX_OTP_ATTEMPTS = 5 // Tentatives max avant blocage -CODE_TTL_VERIFY = 10 * 60 // 10 minutes (en secondes) -CODE_TTL_RESET = 15 * 60 // 15 minutes -``` - -### user.service.js +## Formules de gamification -| Fonction | Description | -|----------|-------------| -| `getUserProfile(userId)` | Retourne user sans password | -| `updateUser(userId, data)` | Mise à jour profil (whitelist de champs) | -| `deleteAccount(userId)` | Suppression cascade : ExerciseRecord → Workout → User | -| `addExperience(userId, xp)` | `user.xp += xp`, recalcule level = `floor(sqrt(xp / 250))` | +### Courbe XP et niveau -### workout.service.js +Source de vérité unique : `utils/levelHelpers.js`, identique à la formule utilisée côté front. -| Fonction | Description | -|----------|-------------| -| `createWorkout(userId, data)` | Crée et sauvegarde une séance | -| `getMyWorkouts(userId)` | Toutes les séances, triées par date desc | -| `getWorkoutById(userId, id)` | Détail + vérification ownership | -| `deleteWorkout(userId, id)` | Suppression (ownership check) | -| `createDraft(userId, data)` | Brouillon (status: "draft") | -| `updateDraft(userId, id, patch)` | Mise à jour partielle du brouillon | -| `finalizeWorkout(userId, id, opts)` | Calcule totaux, applique l'anti-cheat, crédite XP et recalcule le niveau | -| `completeWorkout(userId, id)` | `xp = 100 + 10×exercisesWithSets`, crédite XP et recalcule le niveau | - -**Anti-cheat temporel (miroir du front-end) :** ```javascript -if (shortSession === true || duration < 300) xp = 0 // < 5 min → 0 XP -else if (duration < 900) xp = round(xp/10) // 5–15 min → XP ÷ 10 -// ≥ 15 min → XP plein -``` +xpForLevel(n) = Math.round(4665 * (1.03 ** min(n, 200) - 1)) +levelFromXP(xp) // recherche binaire inverse, plafonnée au niveau 200 -**Formule de niveau (harmonisée avec le front-end) :** -```javascript -// utils/levelHelpers.js — source de vérité unique -xpForLevel(n) = Math.round(4665 * (1.03^n - 1)) -levelFromXP(xp) // recherche binaire inverse -// Exemples : ~1 600 XP → L10 · ~85 000 XP → L100 · ~1 150 000 XP → L200 +// Repères : niveau 1 environ 140 XP, niveau 10 environ 1600 XP, +// niveau 100 environ 85 000 XP, niveau 200 environ 1 720 000 XP ``` -### email.service.js - -Templates HTML professionnels (fond dark `#0D1018`, logo Athly) pour : -- **Vérification d'email** : code OTP 6 chiffres en grande police -- **Reset password** : même format, texte différent +### Rangs -Utilise Nodemailer avec SSL/TLS sur le port 465. +Dix paliers, du niveau 1 au niveau 200 et au-delà : Novice, Initié (11), Athlète (31), Compétiteur (51), Warrior (71), Élite (91), Maître (111), Grand Maître (141), Légende (171), ATHLY GOD (200). -### wger.service.js +### Bonus de groupe (lobby multijoueur) -Proxy vers `https://wger.de/api/v2` : - -```javascript -getExercisesByMuscleAndEquipment(muscleId, equipmentId, includeDetails) -// → [{id, name, videoUrl}] -``` +| Membres | Bonus d'XP | +|---------|------------| +| 2 | 15 pour cent | +| 3 | 25 pour cent | +| 4 | 35 pour cent | +| 5 | 50 pour cent | --- -## 🧪 Tests +## Tests ```bash -# Lancer tous les tests -npm test - -# Tests en mode watch -npm test -- --watch - -# Couverture de code -npm test -- --coverage +npm test # suite complète +npm test -- --watch # mode watch +npm test -- --coverage # couverture de code ``` -### Tests unitaires et d'intégrité (sans base de données) +26 fichiers de tests, exécutés contre une instance MongoDB en mémoire (mongodb-memory-server), démarrée et arrêtée automatiquement par `tests/globalSetup.js` et `tests/globalTeardown.js`. Aucune connexion réseau requise, y compris en intégration continue. -Ces trois suites tournent sans connexion MongoDB — elles sont la cible principale de la CI. - -#### `tests/levelHelpers.test.js` — 24 tests - -Vérifie la formule XP/niveau définie dans `utils/levelHelpers.js` : - -| Groupe | Ce qui est testé | -|--------|-----------------| -| `xpForLevel` | Niveau 0, 1, 10, 100, 200 ; cap >200 ; valeurs négatives ; progression strictement croissante | -| `levelFromXP` | 0 XP → L0 ; valeurs nulles/NaN/négatives ; bijectivité `levelFromXP(xpForLevel(n)) === n` pour n ∈ {1,5,10,25,50,75,100,150,200} ; 85 000 XP → L100 | - -#### `tests/workoutAnticheat.test.js` — 13 tests - -Vérifie la logique d'anti-cheat serveur dans `workout.service.js::finalizeWorkout` via `jest.mock()` (aucun appel MongoDB) : - -| Scénario | XP attendu | -|----------|-----------| -| `durationSeconds = 0` | 0 | -| `durationSeconds = 150` (< 5 min) | 0 | -| `durationSeconds = 299` | 0 | -| `shortSession: true` + 1 800 s | 0 | -| `durationSeconds = 300` (seuil exact) | XP ÷ 10 | -| `durationSeconds = 600` | XP ÷ 10 | -| `durationSeconds = 899` | XP ÷ 10 | -| `durationSeconds = 900` (seuil exact) | XP plein | -| `durationSeconds = 3 600` | XP plein | -| User null (absent en BDD) | pas de crash | -| Workout introuvable | lance une erreur | - -#### `tests/modelsIntegrity.test.js` — 36 tests - -Vérifie l'état des schémas Mongoose sans requête réseau : - -| Groupe | Ce qui est testé | -|--------|-----------------| -| Imports réels | User, Workout, ExerciseRecord s'importent sans erreur | -| Modèles fantômes | UserQuest, RefreshToken, WorkoutLog, RitualLog, UserProgress, Notification → `MODULE_NOT_FOUND` | -| Schéma User | Champs email/password/xp/level/isVerified ; contrainte unique email ; xp défaut 0 ; level défaut 1 | -| Schéma Workout | Champs user/exercises/xpEarned/durationSeconds/status/notes ; méthodes `finalize()` et `computeTotals()` ; enum status contient draft/in_progress/finished | -| Schéma ExerciseRecord | Champs user/workout/exerciceNom/series ; refs User et Workout | - -### Tests d'intégration HTTP (avec base de données) - -| Fichier | Routes couvertes | -|---------|-----------------| -| `health.test.js` | `GET /health` | -| `auth.test.js` | Register, login, verify-email, forgot/reset password | -| `user.test.js` | `GET/PUT /me`, delete account | -| `workout.test.js` | CRUD séances, draft, finalize, complete | -| `exercise.test.js` | Create record, get history, get by workout | - -Ces tests utilisent **Supertest** et nécessitent un cluster MongoDB accessible (variable `MONGO_URI`). +| Fichier | Périmètre | +|---------|-----------| +| `levelHelpers.test.js` | Formule XP et niveau : bornes, plafond, bijectivité | +| `workoutAnticheat.test.js` | Anti-triche serveur sur la finalisation de séance | +| `modelsIntegrity.test.js` | Intégrité des 8 schémas Mongoose | +| `health.test.js` | Point de santé | +| `auth.test.js` | Inscription, connexion, vérification, mot de passe oublié | +| `googleAuth.test.js` | Connexion et création de compte via Google OAuth | +| `discriminator.test.js` | Unicité du combo pseudo et discriminant | +| `user.test.js` | Profil, cadre, vitrines, push token, onboarding, synchronisation XP, suppression de compte | +| `workout.test.js` | CRUD séances, brouillon, finalisation, complétion | +| `exercise.test.js` | Enregistrement de performances, historique, classement | +| `friendship.test.js` | Demandes d'amis, acceptation, refus, retrait, recherche | +| `socialEngine.test.js` | Classement, profil public, niveaux d'amitié | +| `groupStreak.test.js` | Groupes, invitations, streak collective, Secouer | +| `inventory.test.js` | Ouverture de coffres, consommation atomique, réclamation de cosmétiques | +| `reward.test.js` | Trophées serveur, synchronisation, anniversaire | +| `trophyUnification.test.js` | Cohérence du catalogue combiné local plus serveur | +| `bloodSangRewards.test.js` | Attribution du cosmétique Unique de groupe | +| `referral.test.js` | Parrainage, récompenses, garde-fous anti-triche | +| `titles.test.js` | Déblocage et équipement des titres | +| `activityFeed.test.js` | Flux d'activité et réactions | +| `weight.test.js` | Historique de pesées | +| `workoutLobby.test.js` | Cycle de vie complet du lobby multijoueur | +| `debug.test.js` | Endpoints God Mode, blocage en production | +| `profanityFilter.test.js` | Filtre de pseudos | +| `deepIntegration.test.js` | Scénarios croisés de bout en bout | --- -## ⚙️ Intégration continue (CI) - -Les tests unitaires et d'intégrité (`levelHelpers`, `workoutAnticheat`, `modelsIntegrity`) sont conçus pour s'exécuter **sans base de données** dans n'importe quel environnement CI/CD. - -Exemple de workflow GitHub Actions : +## Intégration continue -```yaml -name: Tests +Le workflow GitHub Actions (`.github/workflows/ci.yml`) exécute deux jobs indépendants, chacun déclenché uniquement si son dossier a changé : -on: [push, pull_request] +- **Backend** : installation, lint ESLint, vérification syntaxique de `server.js`, audit de sécurité npm sur les dépendances de production (bloquant à partir du niveau élevé), puis suite de tests complète. Aucune base de données externe n'est requise, la suite Jest démarre sa propre instance en mémoire. +- **Frontend** : installation avec `--legacy-peer-deps`, audit de sécurité npm, puis build de la PWA via `expo export --platform web`, qui détecte immédiatement tout composant natif non compatible avec le web. -jobs: - unit-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - run: npm ci - - run: npx jest tests/levelHelpers.test.js tests/workoutAnticheat.test.js tests/modelsIntegrity.test.js --runInBand --forceExit -``` - -> Les tests d'intégration HTTP (`auth`, `user`, `workout`, `exercise`) nécessitent un secret `MONGO_URI` configuré dans les variables d'environnement du runner CI. +Un push qui ne touche que `front/` ne déclenche pas le job backend, et inversement. --- -## 🔒 Sécurité +## Sécurité | Mesure | Implémentation | -|--------|---------------| -| Passwords hashés | bcrypt avec salt rounds = 10 | -| JWT court-vécu | Expiration 1 jour (configurable) | -| Vérification email | OTP 6 chiffres, expiration 10 min | +|--------|-----------------| +| Mots de passe hashés | bcrypt | +| JWT à courte durée de vie | Expiration configurable, 1 jour par défaut | +| Vérification email | OTP à 6 chiffres, expiration 10 minutes | | Brute-force OTP | Blocage après 5 tentatives | -| Headers sécurisés | Helmet (X-Frame-Options, CSP, HSTS, etc.) | -| CORS | Configuré pour les origines autorisées | -| Validation entrées | Joi sur tous les body de requête | -| Ownership check | Chaque workout/record vérifié contre `req.user.id` | -| Logs HTTP | Morgan en mode `dev` | +| Headers sécurisés | Helmet (CSP, HSTS, noSniff, frameguard) | +| CORS | Allowlist via `CORS_ORIGINS`, permissif uniquement en développement | +| Rate limiting global | 300 requêtes par 15 minutes et par IP sur `/api` | +| Rate limiting authentification | 20 requêtes par 15 minutes, clé IP plus email ciblé | +| Injection NoSQL | Assainissement récursif des clés suspectes avant toute route | +| Validation des entrées | Schémas Joi sur toutes les routes à corps de requête | +| Limite de payload | 1 Mo maximum par requête | +| Vérification de propriété | Chaque séance ou record est vérifié contre `req.user.id` | +| Cache | `Cache-Control: no-store` sur toutes les réponses `/api` | +| Logs HTTP | Morgan, désactivé pendant les tests | | Variables sensibles | Jamais en dur, toujours via `.env` | +Détail complet de l'architecture de sécurité et de résilience : `docs/ARCHITECTURE-SECURITE.md` à la racine du dépôt. + ---
-**Athly API** · Node.js + Express + MongoDB · Authentification JWT + OTP +Athly API : Node.js, Express, MongoDB. Authentification par mot de passe, OTP et Google OAuth.
diff --git a/front/README.md b/front/README.md index 0e59363..b5aee07 100644 --- a/front/README.md +++ b/front/README.md @@ -1,636 +1,539 @@
-# 🏋️ ATHLY — Application Mobile de Fitness Gamifiée +# ATHLY : Application Mobile de Fitness Gamifiée -**Tracker d'entraînement React Native · Stockage local AsyncStorage · Gamification complète** +**React Native, Expo, PWA. Stockage local AsyncStorage, gamification complète, social et multijoueur** ![React Native](https://img.shields.io/badge/React_Native-0.81.5-61DAFB?style=flat-square&logo=react) -![Expo](https://img.shields.io/badge/Expo-54.0.27-000020?style=flat-square&logo=expo) -![TypeScript](https://img.shields.io/badge/JavaScript-ES2022-F7DF1E?style=flat-square&logo=javascript) +![Expo](https://img.shields.io/badge/Expo-54-000020?style=flat-square&logo=expo) +![React Navigation](https://img.shields.io/badge/React_Navigation-7-6b52ae?style=flat-square) ![AsyncStorage](https://img.shields.io/badge/Stockage-AsyncStorage-34D399?style=flat-square) -![Axios](https://img.shields.io/badge/Axios-1.16.0-5A29E4?style=flat-square) +![Axios](https://img.shields.io/badge/Axios-1.18-5A29E4?style=flat-square) -> Athly transforme chaque séance d'entraînement en une expérience de jeu. XP, streaks, quêtes quotidiennes, trophées, rituels de récupération — progresser n'a jamais été aussi addictif. +Athly transforme chaque séance d'entraînement en expérience de jeu : XP, niveaux, streaks, quêtes quotidiennes, trophées, titres RPG, coffres à ouvrir, groupes d'amis avec streak collective, lobby multijoueur, et un tutoriel interactif qui accompagne la découverte de toutes ces fonctionnalités.
--- -## 📋 Table des matières - -1. [Vue d'ensemble](#-vue-densemble) -2. [Fonctionnalités](#-fonctionnalités) -3. [Stack technique](#-stack-technique) -4. [Architecture](#-architecture) -5. [Installation & lancement](#-installation--lancement) -6. [Variables d'environnement](#-variables-denvironnement) -7. [Structure du projet](#-structure-du-projet) -8. [Système de gamification](#-système-de-gamification) -9. [Contextes React](#-contextes-react) -10. [Services](#-services) -11. [Navigation](#-navigation) -12. [Composants clés](#-composants-clés) -13. [Backend associé](#-backend-associé) +## Table des matières + +1. [Vue d'ensemble](#vue-densemble) +2. [Fonctionnalités](#fonctionnalités) +3. [Stack technique](#stack-technique) +4. [Architecture](#architecture) +5. [Installation et lancement](#installation-et-lancement) +6. [Variables d'environnement](#variables-denvironnement) +7. [Structure du projet](#structure-du-projet) +8. [Navigation](#navigation) +9. [Contextes React](#contextes-react) +10. [Services](#services) +11. [Catalogues de données](#catalogues-de-données) +12. [Système de gamification](#système-de-gamification) +13. [Tutoriel interactif](#tutoriel-interactif) +14. [Paramètres développeur](#paramètres-développeur) +15. [Tests](#tests) +16. [Backend associé](#backend-associé) --- -## 🎯 Vue d'ensemble +## Vue d'ensemble -Athly est une application mobile de fitness construite avec React Native (Expo). L'application **nécessite une connexion Internet et un compte** pour fonctionner : l'authentification passe par le backend (JWT). Une fois connecté, toutes les données de progression (logs de séances, XP, quêtes, rituels) sont stockées localement dans AsyncStorage sur l'appareil pour des performances optimales, et synchronisées avec le backend en best-effort. +Athly est une application mobile construite avec React Native et Expo, également buildée en PWA pour le web. Elle nécessite une connexion Internet et un compte pour fonctionner : l'authentification (mot de passe, Google OAuth, vérification email) passe obligatoirement par le backend. Une fois connecté, les données de progression immédiate (logs de séances, XP cumulatif, streak, quêtes, rituels) sont calculées et stockées localement dans AsyncStorage pour des performances instantanées, tandis que le backend fait autorité pour le profil, le social, l'inventaire et le multijoueur. ### Philosophie | Principe | Description | -|----------|-------------| -| **Backend requis** | L'authentification (connexion, inscription, reset password) nécessite une connexion au backend. Sans réseau, l'app n'est pas utilisable. | -| **Données locales** | Une fois connecté, les logs de séances, XP, quêtes et rituels sont stockés dans AsyncStorage — les calculs se font entièrement côté client. | -| **Sync best-effort** | La finalisation d'une séance tente une sync backend, mais l'échec ne bloque pas l'utilisateur. Les données locales font foi. | -| **Gamification profonde** | Chaque séance rapporte des XP, alimente une streak, valide des quêtes et peut débloquer des trophées. | -| **Anti-triche intégré** | Une séance < 5 min ne rapporte aucun XP, ne valide pas les quêtes et n'incrémente pas la streak. | -| **Récupération active** | Les jours sans séance, 5 rituels de récupération permettent de maintenir la streak (+20 à +100 XP). | +|----------|--------------| +| Backend requis | L'authentification nécessite une connexion réseau. Sans elle, l'application n'est pas utilisable. | +| Données de séance locales | Logs, XP, streak, quêtes et rituels vivent dans AsyncStorage, les calculs se font entièrement côté client. | +| Synchronisation best effort | L'XP totale est poussée vers le backend (`POST /users/me/sync-xp`) à chaque événement clé, avec ratchet anti-régression côté serveur. Un échec réseau ne bloque jamais l'utilisateur, un retry automatique a lieu au prochain événement. | +| Social et multijoueur sur backend | Amis, groupes de streak, lobby, inventaire, titres et trophées serveur sont gérés en direct par l'API : ils nécessitent le réseau. | +| Gamification profonde | Chaque séance rapporte de l'XP, alimente une streak, valide des quêtes, peut débloquer des trophées et des titres. | +| Anti-triche intégré | Une séance de moins de 5 minutes ne rapporte aucun XP, ne valide aucune quête et n'incrémente pas la streak. Entre 5 et 15 minutes, l'XP est divisé par 10. Le serveur applique la même règle à la finalisation. | +| Récupération active | Les jours sans séance, 5 rituels permettent de maintenir la streak. | --- -## ✨ Fonctionnalités - -### 🏋️ Gestion des séances -- **Timer en temps réel** pendant l'entraînement avec chronomètre visible -- **Sets & reps** : saisie poids/reps pour chaque exercice, validation par set -- **Supersets** : regroupement de plusieurs exercices en circuit -- **Notes** par séance et par exercice -- **Workout Builder** : créer des séances à partir d'un catalogue de 100+ exercices -- **Exercices personnalisés** : créer et gérer ses propres mouvements (persistés localement) -- **Séances favorites** : sauvegarder et réutiliser ses programmes -- **WorkoutRecapModal** : récapitulatif complet post-séance (volume, XP gagné, nouveaux PRs, quêtes validées) - -### 🛡️ Système Anti-triche (5 minutes) -- Une séance finalisée en **< 5 minutes** déclenche une modale d'avertissement -- L'utilisateur peut choisir de **continuer** ou de **forcer la validation** -- En cas de forçage : `xpEarned = 0`, flag `shortSession: true`, **aucune quête validée**, **aucun incrément de streak** -- **God Mode** : toggle en paramètres développeur pour bypasser le check - -### 🎮 Gamification - -#### XP & Niveaux -- Chaque séance rapporte de l'XP calculé sur le volume, le nombre de sets et le multiplicateur de streak -- Courbe de progression **exponentielle** (base 4665, taux 1.03) -- Cap quotidien : **2 séances XP/jour** maximum (anti-farming) -- Les rituels de récupération ne comptent pas dans le cap quotidien - -#### Streak -- La streak s'incrémente si au moins **1 log valide** (pas `shortSession`) est posé dans la journée -- Les rituels de récupération comptent pour la streak -- **8 paliers de multiplicateur** : jusqu'à ×7.0 à 730 jours consécutifs - -#### Quêtes quotidiennes -- **3 quêtes** tirées parmi 20 templates, déterministes par la date (reproductibles) -- Exemples : "Faire 25 séries", "Terminer en < 45 min", "Travailler les jambes", "Battre un PR" -- Compléter les 3 quêtes débloque un **bonus de 1000 XP supplémentaires** -- Les séances `shortSession` ne valident **aucune** quête - -#### Trophées -- **50+ trophées** catégorisés (Régularité, Volume, Force, Diversité, Spéciaux) -- Évaluation automatique post-séance et au chargement du profil -- **Trophy Room** : galerie avec états verrouillé/débloqué, animations - -### 🧘 Rituels de Récupération Active -5 rituels disponibles les jours sans séance (ou en complément) : +## Fonctionnalités -| Rituel | Mécanique | XP | -|--------|-----------|-----| -| **Mobilité & Souplesse** | Timer 5 min | +20 XP | -| **Marche Quotidienne** | Timer 15 min | +100 XP | -| **Respiration & Mental** | Cercle animé inspire/expire · 5 min | +20 XP | -| **Automassage** | 5 zones × 1 min (Mollets → Épaules) | +20 XP | -| **Focus & Culture** | Article à lire · timer 5 min bloquant | +20 XP | - -- 1 rituel maximum par jour calendaire -- Compte pour la streak (même valeur qu'une vraie séance) -- XP non soumis au cap quotidien de 2 séances - -### 📊 Statistiques -- Volume total, sets complétés, distribution musculaire (camembert) -- Graphe de progression par exercice (LineChart) -- Historique complet des séances avec filtres et tri -- Nouveaux PRs (Personal Records) détectés automatiquement après chaque séance - -### 🎓 Tutoriel interactif -- Système **step-by-step** avec overlay semi-transparent -- Met en surbrillance les éléments de l'UI ciblés -- Auto-scroll vers les éléments mis en avant -- Chapitres : **Dashboard** (6 étapes) + **Workout** (6 étapes) -- Inclut une étape dédiée au système anti-triche et aux rituels - -### 👤 Profil & Personnalisation -- Données physiques (poids, taille, âge, objectif, rythme) -- Équipements disponibles pour la recommandation d'exercices -- **Thèmes de profil** : plusieurs palettes visuelles -- Suppression de compte RGPD (cascade sur toutes les données) +### Gestion des séances + +Timer en temps réel, saisie poids et répétitions par série, supersets, notes par séance et par exercice, Workout Builder pour générer une séance sur mesure à partir d'un catalogue de plus de 350 exercices, création manuelle exercice par exercice, exercices personnalisés persistés localement, séances sauvegardées comme modèles réutilisables (modifiables après coup, avec les mêmes critères de génération pré-remplis), récapitulatif complet post-séance (volume, XP gagné, nouveaux records, quêtes validées). + +### Anti-triche + +Une séance finalisée en moins de 5 minutes déclenche une modale d'avertissement. L'utilisateur peut modifier la séance ou forcer la validation : dans ce cas, l'XP gagné est nul, la séance est marquée `shortSession`, aucune quête n'est validée et la streak n'est pas incrémentée. Le backend applique la même logique à la finalisation, indépendamment du client. + +### Gamification + +XP et niveaux sur une courbe exponentielle, streak avec 8 paliers de multiplicateur jusqu'à 7 fois l'XP de base, 3 quêtes quotidiennes tirées parmi 20 modèles de façon déterministe par la date (tout le monde a les mêmes quêtes le même jour), rangs de Novice à ATHLY GOD, catalogue de plus de 60 trophées réparti en 9 catégories, 17 titres RPG déblocables et équipables sous le pseudo. + +### Coffres et inventaire + +Un coffre toutes les 2 heures de séance cumulées, débloqué au rang Initié (niveau 11). Ouverture avec animation, table de drop par rareté (commune, rare, épique, légendaire, unique), objets consommables (boissons d'XP, gels de streak, boosts de multiplicateur, coupons de niveau), cosmétiques Unique Rouge Sang réclamables sous conditions rares. + +### Social + +Ajout d'ami par tag unique façon Discord, aperçu de profil avant envoi de la demande, classement XP entre amis, classement par exercice, profil public consultable, niveaux d'amitié progressifs, flux d'activité avec réactions (bravo, respect, hue, jaloux) sur les records et coffres légendaires des amis. + +### Groupes de streak + +Groupe de 5 membres maximum, invitations, streak collective (validée si tous les membres valident leur journée), météo des séances en direct (statut prêt, actif, validé de chaque membre), bouton Secouer pour relancer un retardataire par notification, Hall of Shame si la streak casse, récompense cosmétique Unique à 30 jours de streak collective à taille maximale. + +### Lobby multijoueur + +Invitation d'amis pour démarrer une séance ensemble, chacun sur son propre appareil, bonus d'XP de groupe croissant selon le nombre de participants (15 à 50 pour cent). + +### Statistiques + +Volume total, sets complétés, répartition musculaire en camembert, graphe de progression par exercice, suivi de poids avec objectif et rappel hebdomadaire, historique complet des séances avec filtres, détection automatique des nouveaux records personnels. + +### Profil et personnalisation + +Données physiques, équipements disponibles, cadre de profil personnalisable (forme et couleur), thèmes visuels débloqués par la progression, vitrine de trophées et de records mis en avant, roadmap des rangs, célébration d'anniversaire, système de parrainage avec récompenses pour les deux parties, suppression de compte RGPD en cascade. + +### Tutoriel interactif + +Système de spotlight en huit chapitres qui met en surbrillance les éléments réels de l'interface, avec démonstrations en direct, auto-scroll vers les éléments ciblés, et persistance de la complétion à la fois localement et côté serveur pour une cohérence entre appareils. --- -## 🛠 Stack technique +## Stack technique | Couche | Technologie | Version | |--------|-------------|---------| | Framework | React Native | 0.81.5 | -| Environnement | Expo | ~54.0.27 | -| Navigation | React Navigation | 7.x | +| Runtime React | React | 19.1.0 | +| Environnement | Expo | 54.x | +| Navigation | React Navigation (native, bottom-tabs, stack, native-stack) | 7.x | | Stockage local | AsyncStorage | 2.2.0 | -| Token sécurisé | Expo SecureStore | ~15.0.8 | -| HTTP client | Axios | ^1.16.0 | -| Animations | Lottie React Native | ~7.3.1 | -| Graphiques | React Native Chart Kit | ^6.12.2 | -| SVG | React Native SVG | 15.12.1 | -| Icônes | @expo/vector-icons (Ionicons) | ^15.0.3 | -| Haptics | Expo Haptics | ~15.0.8 | -| Gradients | Expo Linear Gradient | ~15.0.8 | -| Gestures | React Native Gesture Handler | ~2.28.0 | +| Token sécurisé | Expo SecureStore | 15.x | +| HTTP client | Axios | 1.18 | +| Animations | Lottie React Native | 7.3.1 | +| Graphiques | React Native Chart Kit | 6.12 | +| SVG | React Native SVG | 15.12 | +| Icônes | Expo Vector Icons (Ionicons) | 15.x | +| Haptique | Expo Haptics | 15.x | +| Dégradés | Expo Linear Gradient | 15.x | +| Gestes | React Native Gesture Handler | 2.28 | +| Notifications | Expo Notifications | 0.32 | +| Authentification Google | Expo Auth Session | 7.x | +| Support web | React Native Web | 0.19 | +| Variables d'environnement | react-native-dotenv | 3.x | +| Tests | Jest, Babel Jest | 29.x | --- -## 🏗 Architecture +## Architecture + +### Répartition backend et stockage local + +``` +Nécessite le backend, réseau requis + - Connexion, inscription, réinitialisation de mot de passe, connexion Google + - Profil utilisateur, cadre équipé, vitrines + - Amis, groupes de streak, lobby multijoueur, inventaire, coffres + - Titres RPG, trophées serveur, parrainage, anniversaire + - Synchronisation de l'XP totale (best effort, ne bloque jamais) + +Stocké localement (AsyncStorage) + - Logs de séances, XP cumulatif, streak + - Quêtes quotidiennes et état du bonus + - Rituels de récupération + - Exercices personnalisés, séances sauvegardées + - Progression et complétion du tutoriel interactif + - Paramètres développeur (God Mode, bypass anti-triche) +``` -### Répartition backend / stockage local +### Arbre de providers ``` -┌───────────────────────────────────────────────────────────────┐ -│ NÉCESSITE LE BACKEND (réseau requis) │ -│ • Connexion / Inscription / Reset password │ -│ • Récupération du profil utilisateur │ -│ • Catalogue d'exercices WGER │ -│ • Sync séances (best-effort, ne bloque pas si KO) │ -└───────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────┐ -│ STOCKÉ LOCALEMENT (AsyncStorage) │ -│ • Logs de séances, XP cumulatif, streak │ -│ • Quêtes quotidiennes et état bonus │ -│ • Rituels de récupération │ -│ • Exercices personnalisés, séances favorites │ -│ • Paramètres développeur (God Mode, bypass…) │ -└───────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────┐ -│ COMPOSANT UI │ -└────────────────────┬────────────────────────────────┘ - │ -┌────────────────────▼────────────────────────────────┐ -│ CONTEXT (état global React) │ -│ WorkoutLogsContext · QuestContext · TutorialContext │ -└───────────┬─────────────────────────┬───────────────┘ - │ read/write │ best-effort -┌───────────▼──────────┐ ┌─────────▼──────────────┐ -│ AsyncStorage │ │ API Backend │ -│ (source de vérité │ │ (auth + sync) │ -│ pour les logs) │ │ │ -└──────────────────────┘ └────────────────────────-┘ +NavigationContainer + TutorialProvider + UserProvider + WorkoutLogsProvider + SavedWorkoutsProvider + CustomExercisesProvider + QuestProvider + AuthStack (non connecté) + ou BottomTabs (connecté), avec en overlay : + BirthdayCelebration, LevelUpCelebration, + ActivityFeedModal, WeightReminderCheck, LobbyInviteCheck ``` -### Finalisation d'une séance (WorkoutInProgressContext) +### Finalisation d'une séance ``` handleTerminate() - │ - ├── elapsed < 5 min && !bypassAnticheat ? - │ └── ShortSessionWarningModal - │ ├── "Modifier" → retour séance - │ └── "Forcer" → finalizeWithLog({ shortSession: true }) - │ - └── finalizeWithLog({ durationSeconds, notes, ... }) - │ - ├── buildLogFromWorkout() // calcul XP, volume, muscleDistribution - ├── shortSession ? xpEarned = 0 : normal - ├── workoutLogs.create(log) // AsyncStorage (toujours) - ├── !shortSession → questContext.checkAndUpdateQuests() - └── bundle.actions.finalize() // sync backend (best-effort) + elapsed < 5 minutes et bypass non activé + -> ShortSessionWarningModal + Modifier : retour à la séance + Forcer : finalise avec shortSession = true + + finalizeWithLog(...) + buildLogFromWorkout() calcule XP, volume, répartition musculaire + shortSession ? xpEarned = 0 : calcul normal avec multiplicateur de streak + workoutLogs.create(log) écriture AsyncStorage, toujours exécutée + !shortSession -> questContext.checkAndUpdateQuests() + actions.finalize() synchronisation backend, best effort + xpSync.service -> POST /users/me/sync-xp avec le nouveau total ``` --- -## 🚀 Installation & lancement +## Installation et lancement ### Prérequis -- Node.js ≥ 18 -- npm ou yarn +- Node.js 18 ou supérieur - Expo CLI (`npm install -g expo-cli`) -- Expo Go sur votre téléphone (iOS ou Android) **ou** un émulateur +- Expo Go sur un téléphone, ou un simulateur iOS ou Android, ou un navigateur pour la version web ### Étapes ```bash -# 1. Cloner le dépôt git clone https://github.com/ClemLy/Athly.git cd Athly/front -# 2. Installer les dépendances npm install -# 3. Configurer les variables d'environnement cp .env.example .env -# Éditer .env avec l'URL de votre backend (voir section suivante) - -# 4. Lancer le serveur de développement -npm start +# Éditer .env avec l'URL de votre backend, voir la section suivante -# Alternatives ciblées -npm run android # Lancer sur émulateur Android -npm run ios # Lancer sur simulateur iOS (macOS requis) -npm run web # Lancer dans le navigateur +npm start # Metro bundler, QR code Expo Go +npm run android # émulateur Android +npm run ios # simulateur iOS, macOS requis +npm run web # navigateur +npm run build:web # build PWA de production (expo export puis copie du service worker) ``` -Expo affichera un QR code à scanner avec Expo Go sur votre téléphone. - --- -## 🔑 Variables d'environnement - -Créer un fichier `.env` à la racine de `athly-app/` : +## Variables d'environnement ```env -# URL de l'API backend (remplacer par votre IP locale en développement) +# URL de l'API backend. En développement local, utiliser l'IP de la machine +# sur le réseau local, jamais "localhost" : un téléphone physique ne peut pas +# le résoudre. API_URL=http://VOTRE_IP_LOCALE:4000/api -# Clé de stockage du token JWT dans SecureStore +# Clé de stockage du token JWT dans Expo SecureStore TOKEN_KEY=athly_token -# Environnement (development | production) +# development ou production APP_ENV=development + +# Client IDs Google OAuth (un par plateforme, type d'application différent +# pour chacun). Tant qu'ils ne sont pas renseignés, le bouton Google reste +# désactivé côté client. +GOOGLE_EXPO_CLIENT_ID= +GOOGLE_IOS_CLIENT_ID= +GOOGLE_ANDROID_CLIENT_ID= +GOOGLE_WEB_CLIENT_ID= ``` -> **Important :** En développement local, utilisez votre adresse IP sur le réseau local (pas `localhost` — React Native ne peut pas résoudre `localhost` sur téléphone physique). +Trouver son IP locale : `ipconfig` sous Windows, `ifconfig` ou `ip addr` sous macOS et Linux. --- -## 📁 Structure du projet +## Structure du projet ``` -athly-app/ -├── index.js # Point d'entrée Expo -├── App.js # Root : providers + navigation -├── app.json # Configuration Expo -├── .env # Variables d'environnement -│ -├── assets/ -│ ├── icon.png, adaptive-icon.png, splash-icon.png -│ ├── logo-orange.png, logo-violet.png -│ └── animations/ -│ └── confetti.json # Animation Lottie (quête bonus) -│ -└── src/ - ├── api/ - │ └── api.js # Instance Axios (JWT auto-inject, 401 handler) - │ - ├── context/ # État global React (9 contextes) - │ ├── AuthContext.js - │ ├── UserContext.js - │ ├── WorkoutInProgressContext.js - │ ├── WorkoutLogsContext.js - │ ├── CustomExercisesContext.js - │ ├── SavedWorkoutsContext.js - │ ├── QuestContext.js - │ ├── TutorialContext.js - │ └── ToastContext.js - │ - ├── screens/ # 23 écrans - │ ├── Auth/ # AuthScreen, Login, Register, Verify, ForgotPassword - │ ├── Home/ # HomeScreen - │ ├── Workouts/ # WorkoutScreen, Builder, List, Detail, ExerciseStats… - │ ├── Profile/ # Profile, Edit, Settings, RankRoadmap, TrophyRoom - │ └── Stats/ # StatsScreen - │ - ├── components/ # 47 composants réutilisables - │ ├── home/ # DailyQuestsCard, RecoveryRitualsCard, QuickStatsRow… - │ ├── workouts/ # SetTable, AddExerciseSheet, ShortSessionWarningModal… - │ ├── cards/ # ExerciseCard, WorkoutItem, StatBox… - │ ├── profile/ # TrophyGrid, EmberParticles - │ ├── tutorial/ # TutorialOverlay - │ └── ui/ # AppToast - │ - ├── services/ # Logique métier - │ ├── stats.service.js # XP, streak, logs AsyncStorage (source de vérité) - │ ├── quest.service.js # 20 templates · 3 quêtes/jour déterministes - │ ├── auth.service.js # API auth - │ ├── workout.service.js # API workouts - │ ├── customExercises.service.js - │ └── savedWorkouts.service.js - │ - ├── hooks/ # Custom hooks - │ ├── useWorkoutState.js # Reducer pattern pour l'état séance - │ ├── useEffortTimer.js # Chronomètre séance - │ ├── useDevSettings.js # Paramètres développeur (God Mode, bypass…) - │ └── useExerciseSorting.js - │ - ├── data/ # Données statiques - │ ├── exerciseCatalog.js # 100+ exercices avec muscles cibles - │ ├── trophyCatalog.js # 50+ définitions de trophées - │ ├── tutorialChapters.js # Étapes du tutoriel - │ ├── workoutTemplates.js # Programmes prédéfinis - │ ├── ritualTypes.js # 5 rituels de récupération - │ └── profileThemes.js # Thèmes visuels du profil - │ - ├── navigation/ - │ ├── index.js # AppNavigator (Auth vs App) - │ ├── AuthStack.js - │ ├── BottomTabs.js # 5 onglets - │ ├── WorkoutStack.js - │ └── ProfileStack.js - │ - ├── constants/ - │ ├── theme.js # Design tokens (Colors, MUSCLE_GROUP_COLORS) - │ └── exerciseFilters.js # Mapping muscles/équipements - │ - └── styles/ - └── global.js # Styles utilitaires partagés +front/ + index.js Point d'entrée Expo + App.js Composant racine, wrapping des providers + app.json Configuration Expo, y compris la PWA + eas.json Profils de build EAS + web/index.html Shell HTML pour le build web + + assets/ + + src/ + api/ + api.js Instance Axios : injection du JWT, gestion du 401 + + context/ 9 contextes React + AuthContext.js + UserContext.js + WorkoutInProgressContext.js + WorkoutLogsContext.js + CustomExercisesContext.js + SavedWorkoutsContext.js + QuestContext.js + TutorialContext.js + ToastContext.js + + screens/ 21 écrans + Auth/ Login, Register, EmailVerification, ForgotPassword + Home/ HomeScreen + Workouts/ WorkoutList, Workout, Builder, ManualCreator, + ExerciseDetail, ExerciseStats, EditExercise, + CustomExercises + Social/ SocialScreen, FriendProfileScreen + Stats/ StatsScreen + Profile/ Profile, EditProfile, Settings, RankRoadmap, + TrophyRoom, Inventory + + components/ Organisés par domaine + home/ Cartes du tableau de bord, rituels de récupération + workouts/ Feuille d'ajout d'exercice, lobby multijoueur, + modale de sauvegarde, avertissement anti-triche + cards/ Cartes d'exercices réutilisables + profile/ Cadre d'avatar, vitrine de trophées, célébrations + social/ Modales d'amis, de groupe, flux d'activité + inventory/ Modale d'ouverture de coffre + stats/ Graphiques, calendrier, historique + tutorial/ Overlay du tutoriel interactif + common/ Modales partagées, barrel d'export + inputs/, ui/, web/ Composants transverses + + services/ Barrel d'export unique, 17 fichiers + stats.service.js XP, streak, logs AsyncStorage, source de vérité locale + quest.service.js Modèles de quêtes, sélection déterministe du jour + auth.service.js, workouts.service.js, savedWorkouts.service.js, + customExercises.service.js, social.service.js, inventory.service.js, + reward.service.js, title.service.js, weight.service.js, + xpSync.service.js, onboarding.service.js, profile.service.js, + lobby.service.js, debug.service.js, haptics.service.js, + notificationService.js + + hooks/ Barrel d'export unique + useWorkoutState.js Pattern reducer pour l'état de séance en cours + useEffortTimer.js Chronomètre de séance + useDevSettings.js Paramètres God Mode + useExerciseSorting.js + useAvatarFrame.js + useFeaturedTrophies.js + useGoogleAuth.js + + data/ Catalogues statiques + exerciseCatalog.js Plus de 350 exercices avec muscles ciblés + trophyCatalog.js Catalogue local de trophées et filtres + backendTrophyCategories.js Correspondance des catégories serveur + tutorialChapters.js 8 chapitres du tutoriel interactif + workoutTemplates.js Programmes prédéfinis + ritualTypes.js 5 rituels de récupération + profileThemes.js Thèmes visuels du profil + majorExercises.js Exercices de référence pour les classements + + navigation/ + index.js AppNavigator : bascule Auth ou App, providers globaux + AuthStack.js + BottomTabs.js 5 onglets + WorkoutStack.js + ProfileStack.js + SocialStack.js + + constants/ + theme.js Jetons de design (Colors, MUSCLE_GROUP_COLORS) + exerciseFilters.js Correspondance muscles et équipements ``` --- -## 🎮 Système de gamification +## Navigation -### Calcul de l'XP par séance +``` +AppNavigator (racine) + Non connecté : AuthStack + Auth (LoginScreen) + Register + EmailVerification + ForgotPassword + + Connecté : BottomTabs, 5 onglets + Accueil HomeScreen + Séances WorkoutStack + WorkoutList, WorkoutBuilder, ManualWorkoutCreator, CustomExercises, + EditExercise, Workout (séance en cours), ExerciseDetail, ExerciseStats + Stats StatsScreen + SocialTab SocialStack + SocialHub, FriendProfile + ProfileTab ProfileStack + ProfileMain, EditProfile, RankRoadmap, TrophyRoom, Settings, Inventory +``` -```javascript -// 1. XP de base (calculé par buildLogFromWorkout) -baseXP = volume * 0.12 + setsCompleted * 8 + totalExercises * 15 +--- -// 2. Multiplicateur de streak -xpEarned = round(baseXP * streakMultiplier) +## Contextes React -// 3. Bonus séance longue (> 15 min) -// Normal : aucun modificateur négatif -// Séance courte (< 15 min mais > 5 min) : XP ÷ 10 -// Séance trop courte (< 5 min, forcée) : XP = 0 -``` +### WorkoutLogsContext -### Niveaux +Source de vérité principale pour l'historique de séances, persistée dans AsyncStorage. Expose la liste complète des logs, les logs de séances seules, les logs comptant pour la streak, l'XP cumulatif, ainsi que les opérations de création, suppression et ajout de rituel. -```javascript -// XP nécessaire pour le niveau N -xpForLevel(n) = 4665 * (1.03^n - 1) / (1.03 - 1) - -// Exemples -Niveau 1 → 140 XP total -Niveau 10 → 1 604 XP total -Niveau 30 → 6 657 XP total -Niveau 100 → 85 000 XP total -``` +### QuestContext -### Multiplicateurs de streak +Les 3 quêtes du jour, le nombre complété, l'état du bonus, et la fonction qui évalue une séance terminée contre les quêtes actives. -| Streak | Multiplicateur | Label | -|--------|---------------|-------| -| 0 jour | ×1.0 | — | -| 3 jours | ×1.1 | On Fire 🔥 | -| 7 jours | ×1.2 | Week Warrior ⚔️ | -| 30 jours | ×1.5 | Godly Streak 👑 | -| 90 jours | ×2.0 | 3 Mois de Feu 🌊 | -| 180 jours | ×3.0 | Semi-Annuel 💎 | -| 365 jours | ×4.5 | Streak Annuel 🌟 | -| 730 jours | ×7.0 | Streak Légendaire ⚡ | +### TutorialContext -### Quêtes quotidiennes +Machine à états du tutoriel interactif : chapitre actif, étape courante, cibles enregistrées par les écrans, fonctions de scroll et de re-mesure, complétion locale et réconciliation avec le flag serveur. + +### WorkoutInProgressContext -3 quêtes sont sélectionnées chaque jour parmi 20 templates (sélection déterministe par hash de la date — tout le monde a les mêmes quêtes le même jour) : +État de la séance en cours (reducer), actions d'ajout et de modification de sets et d'exercices, et la fonction `finalize` qui orchestre le log local, la validation des quêtes et la synchronisation backend. -```javascript -// Exemples de templates -{ id: 'volume_10k', check: log => log.totalVolume >= 10000 } -{ id: 'sets_25', check: log => log.setsCompleted >= 25 } -{ id: 'duration_45', check: log => log.durationSeconds <= 2700 } -{ id: 'legs_day', check: log => log.muscleDistribution.jambes >= 40 } -{ id: 'new_pr', check: (_, prs) => prs.length > 0 } -``` +### UserContext + +Profil utilisateur courant, récupéré depuis le backend, avec fonction de rafraîchissement. + +### AuthContext + +Token JWT, état de chargement initial, connexion et déconnexion. -- Compléter **1 quête** : +500 XP -- Compléter **les 3 quêtes** : +1000 XP bonus (animation confetti) +### SavedWorkoutsContext, CustomExercisesContext + +CRUD des séances sauvegardées et des exercices personnalisés, persistés dans AsyncStorage. + +### ToastContext + +File de notifications toast affichées en overlay. --- -## 🧩 Contextes React +## Services -### WorkoutLogsContext +Tous les services sont exposés via un barrel unique (`src/services/index.js`), ce qui permet des imports courts comme `import { getFriendsList, haptics } from '../services'`. -Source de vérité principale pour l'historique des séances. Persiste dans AsyncStorage. +### stats.service.js -```javascript -const { - items, // Tous les logs (séances + rituels + quêtes) - sessionLogs, // Logs séances uniquement (pas rituels, pas quest_reward) - activityLogs, // Pour calcul streak (pas shortSession, pas quest_reward) - loading, error, - refresh, - create(log), // Ajouter log - remove(id), // Supprimer log - addRitual(ritualId, label, duration, xpEarned), // Rituel de récupération - totalXP, // XP cumulatif - clearAll, // Debug uniquement -} = useWorkoutLogs(); -``` +Le service le plus important : gestion complète des logs AsyncStorage, calcul de l'XP par séance, du streak, du multiplicateur, du niveau, des rangs, de la répartition musculaire, détection des nouveaux records. -### QuestContext +### quest.service.js -```javascript -const { - quests, // [{id, label, icon, completed, xp}] × 3 - bonusClaimed, // Bonus 3/3 quêtes déjà réclamé - completedCount, // 0-3 - checkAndUpdateQuests(log, newPRs), // → {completedQuests, bonusUnlocked, questXP} - refresh, -} = useQuests(); -``` +Chargement des quêtes du jour, sélection déterministe par hash de date parmi 20 modèles, vérification et marquage de complétion. -### TutorialContext +### workouts.service.js -```javascript -const { - isActive, activeChapterId, stepIndex, - hasCompleted, pendingChapterId, - targets, - registerTarget(key, rect), - registerScrollRef(chapterId, ref), - registerRemeasure(chapterId, fn), - scrollToStep(chapterId, y), - startChapter(chapterId), - nextStep, prevStep, finishChapter, - bootstrapped, -} = useTutorial(); -``` +Cycle de vie côté backend des séances : création de brouillon, mise à jour, finalisation, complétion. -### WorkoutInProgressContext +### api.js -```javascript -const { - state, // {id, name, exercises, notes, status, durationSeconds} - dispatch, - actions: { - finalize, // = finalizeWithLog() — log local + sync backend - addSet, updateSet, removeSet, - addExercise, removeExercise, - updateNotes, - }, - loadWorkout(workout), - addExerciseToWorkout(exercise), -} = useWorkoutInProgress(); -``` +Instance Axios : URL de base depuis `.env`, timeout, injection automatique du Bearer token, et sur une réponse 401, déconnexion automatique avec message de session expirée. --- -## ⚙️ Services +## Catalogues de données -### stats.service.js (AsyncStorage) +### exerciseCatalog.js -Le service le plus important — toute la logique locale de logs et de calculs XP. +Plus de 350 exercices, chacun avec son groupe musculaire, son muscle cible principal, ses muscles secondaires, son équipement requis, son niveau de difficulté, et un indicateur de mouvement composé ou d'isolation. -```javascript -// CRUD logs AsyncStorage -listLogs() → WorkoutLog[] -addLog(log) → WorkoutLog (avec cap 2 XP/jour) -removeLog(id) → void -addRitualLog(ritualId, label, duration, xpEarned) → WorkoutLog | null (max 1/jour) - -// Calculs purs -buildLogFromWorkout(stateSnapshot, prevLogs) → WorkoutLog complet -findNewPRsInLog(log, allLogs) → PR[] -computeStreak(logs) → number -getStreakMultiplier(streak) → {multiplier, label, color, tier} -xpForLevel(n) → number -totalCumulativeXP(logs) → number -``` +### trophyCatalog.js -### quest.service.js +40 trophées locaux répartis en 8 catégories (Héritage, Force, Exploration, Secret, Corps, Régularité, Social, Spécial), plus le trophée capstone Souverain Absolu. Combiné avec les 20 trophées serveur (catégorie Collection notamment), le total dépasse 60 trophées répartis en 9 catégories affichées dans la Salle des Trophées. -```javascript -loadTodayQuests() → {date, quests, bonusClaimed} -checkAndMarkQuests(log, prs) → {completedIds, bonusUnlocked} -saveTodayQuests(state) -getTemplateById(id) → Template -``` +### tutorialChapters.js -### api.js (instance Axios) +8 chapitres correspondant aux grands écrans de l'application : Dashboard, Entraînement, Profil et Vitrine, Salle des Trophées, Inventaire, Social, Statistiques, Réglages. -- **URL de base** : `API_URL` depuis `.env` -- **Timeout** : 10 secondes -- **Intercepteur requête** : injecte automatiquement `Authorization: Bearer {token}` -- **Intercepteur réponse** : sur 401 → appelle `signOut()` + message "Session expirée" +### ritualTypes.js ---- +5 rituels de récupération active. -## 🗺 Navigation +| Rituel | Mécanique | XP | +|--------|-----------|-----| +| Mobilité et souplesse | Minuteur de 5 minutes | 20 | +| Marche quotidienne | Minuteur de 15 minutes | 100 | +| Respiration et mental | Cercle animé inspire et expire, 5 minutes | 20 | +| Automassage | 5 zones d'une minute chacune | 20 | +| Focus et culture | Article à lire, minuteur bloquant de 5 minutes | 20 | -``` -AppNavigator (root) -├── {!userToken} AuthStack -│ ├── AuthScreen (landing) -│ ├── LoginScreen -│ ├── RegisterScreen -│ ├── EmailVerificationScreen -│ └── ForgotPasswordScreen -│ -└── {userToken} BottomTabs (5 onglets) - ├── 🏠 HomeScreen - ├── 💪 WorkoutStack - │ ├── WorkoutListScreen - │ ├── WorkoutScreen (séance en cours) - │ ├── WorkoutBuilderScreen - │ ├── WorkoutDetailScreen - │ ├── ManualWorkoutCreatorScreen - │ ├── ExerciseDetailScreen - │ ├── ExerciseStatsScreen - │ ├── EditExerciseScreen - │ └── CustomExercisesScreen - ├── 📊 StatsScreen - └── 👤 ProfileStack - ├── ProfileScreen - ├── EditProfileScreen - ├── SettingsScreen - ├── RankRoadmapScreen - └── TrophyRoomScreen -``` +Un rituel maximum par jour calendaire. Compte pour la streak au même titre qu'une séance, et son XP n'est pas soumis au plafond quotidien. --- -## 🎨 Composants clés - -### `RecoveryRitualsCard` -Carte affichée sur HomeScreen les jours sans séance. Propose 5 rituels de récupération, chacun avec son propre composant interactif : -- `CountdownTimer` — timer circulaire standard (mobilité, marche) -- `BreathingTimer` — cercle animé inspire (4s) / expire (6s) -- `FoamRollingTimer` — 5 zones × 60s avec dots de progression -- `FocusReader` — article aléatoire parmi 3, timer 5 min bloquant +## Système de gamification -### `ShortSessionWarningModal` -Modale animée (spring) déclenchée si la séance dure < 5 min : -- "Modifier la séance" → reprend l'entraînement -- "Valider quand même (0 XP)" → sauvegarde avec `shortSession: true` - -### `WorkoutRecapModal` -Récapitulatif post-séance : -- Volume total, sets complétés, durée, XP gagné -- Nouveaux PRs battus -- Quêtes validées dans la séance -- Animation confetti si bonus 3/3 quêtes +### Calcul de l'XP par séance -### `TutorialOverlay` -Overlay semi-transparent avec : -- Découpe transparente autour de l'élément cible (`registerTarget`) -- Bulle de texte positionnée dynamiquement (top/bottom/center) -- Boutons Précédent / Suivant / Terminer +```javascript +// Par exercice, calculé en local (stats.service.js) +xp += setsCompleted * 10 + (volume * multiplicateur) / 20 +// multiplicateur = 1.2 si l'exercice est un mouvement composé, sinon 1 -### `DailyQuestsCard` -Affiche les 3 quêtes du jour avec progression, labels, icônes et le statut du bonus. +// Multiplicateur de streak appliqué au total +xpEarned = round(xp * streakMultiplier) -### Design System (`src/constants/theme.js`) +// Anti-triche temporel +// moins de 5 minutes, forcé : xpEarned = 0 +// entre 5 et 15 minutes : xpEarned = xpEarned / 10 +// 15 minutes ou plus : XP plein +``` -Toutes les couleurs passent par des tokens centralisés : +### Niveaux ```javascript -Colors.primary // #FE7439 (orange — accent principal) -Colors.secondaryAccent // #6E6AF0 (violet) -Colors.valid // #22C55E (vert validation) -Colors.warningAmber // #F59E0B (avertissement) -Colors.textPrimary // blanc pleine opacité -Colors.textSecondary // blanc ~70% -Colors.textMuted // blanc ~45% -Colors.card // fond carte -Colors.background // fond général (dark) +xpForLevel(n) = round(4665 * (1.03 ** min(n, 200) - 1)) +// niveau 1 environ 140 XP, niveau 10 environ 1600 XP, +// niveau 100 environ 85 000 XP, niveau 200 environ 1 720 000 XP ``` +### Multiplicateurs de streak + +| Streak | Multiplicateur | Label | +|--------|-----------------|-------| +| 0 jour | x1.0 | | +| 3 jours | x1.1 | On Fire | +| 7 jours | x1.2 | Week Warrior | +| 30 jours | x1.5 | Godly Streak | +| 90 jours | x2.0 | 3 Mois de Feu | +| 180 jours | x3.0 | Semi-Annuel | +| 365 jours | x4.5 | Streak Annuel | +| 730 jours | x7.0 | Streak Légendaire | + +### Quêtes quotidiennes + +3 quêtes sélectionnées chaque jour parmi 20 modèles, par un hash déterministe de la date : tout le monde reçoit les mêmes quêtes le même jour. Compléter une quête rapporte de l'XP, compléter les 3 déclenche un bonus supplémentaire avec animation. + +### Rangs + +Novice, Initié à partir du niveau 11, Athlète à 31, Compétiteur à 51, Warrior à 71, Élite à 91, Maître à 111, Grand Maître à 141, Légende à 171, ATHLY GOD à 200. + --- -## 🔗 Backend associé +## Tutoriel interactif + +Système de spotlight en 8 chapitres, un par grande zone de l'application. Chaque étape peut cibler un élément réel de l'écran (mesuré dynamiquement via `useTutorialTarget`) ou afficher une carte centrée, avec positionnement automatique du texte au-dessus ou en dessous de la cible selon l'espace disponible. Auto-scroll vers les éléments hors champ, indicateur de progression Chapitre X sur N, retour haptique sur les actions de navigation. La complétion est persistée à la fois dans AsyncStorage pour une reprise immédiate, et côté serveur (`hasCompletedOnboarding`) pour rester cohérente entre appareils. Rejouable à tout moment depuis les Réglages, chapitre par chapitre. + +--- -Ce dépôt frontend communique avec l'API **Athly Backend** (dépôt séparé). +## Paramètres développeur (God Mode) -Le backend gère : -- Authentification (JWT + OTP email) -- Synchronisation des séances (best-effort depuis le front) -- Catalogue d'exercices (intégration WGER) -- Profil utilisateur +Accessibles depuis Réglages, section God Mode. -> Voir le README du dépôt backend pour l'installation et la documentation complète de l'API. +| Réglage | Effet | +|---------|-------| +| God Mode | Bascule générale utilisée par plusieurs outils de test | +| Bypass anti-triche 5 minutes | Ignore le seuil de durée minimale d'une séance | +| Forcer l'affichage des rituels | Affiche la carte de rituels même après une séance déjà faite | +| Overrides de trophées | Force le déblocage ou le verrouillage d'un trophée pour le tester | -**L'application nécessite le backend pour l'authentification.** Une fois connecté, les données de progression (logs, XP, quêtes, rituels, trophées) sont stockées localement dans AsyncStorage — les calculs sont faits côté client. +Persistés dans AsyncStorage, rechargés à chaque focus de l'écran concerné. --- -## 🔧 Paramètres développeur (God Mode) +## Tests + +```bash +npm test +``` + +3 suites Jest sur les modules JS purs (données et services sans dépendance React Native), exécutées via Babel en environnement Node : intégrité structurelle du catalogue de chapitres du tutoriel, non-régression des rituels de récupération, comportement du service de synchronisation d'XP. + +--- -Accessibles depuis Paramètres → section God Mode (uniquement en mode dev) : +## Backend associé -| Toggle | Effet | -|--------|-------| -| God Mode | Bypass général pour tests | -| Bypass anti-triche 5 min | Ignore le check de durée minimum | -| Forcer l'affichage des rituels | Affiche la carte rituels même après une séance | +Ce dépôt frontend communique avec l'API Athly Backend, dans le dossier `back/` du même dépôt. Le backend gère l'authentification, le profil, le social (amis, groupes, lobby), l'inventaire et les coffres, les titres et trophées serveur, le parrainage, ainsi que la synchronisation de l'XP. Voir `back/README.md` pour l'installation et la documentation complète de l'API. -Ces paramètres sont persistés dans AsyncStorage et se rechargent à chaque focus de l'écran concerné. +L'application nécessite le backend pour l'authentification et pour toutes les fonctionnalités sociales et multijoueur. Les données de séance immédiates (logs, XP local, quêtes, rituels) restent utilisables même en cas de coupure réseau temporaire, la synchronisation reprenant automatiquement au retour du réseau. ---
-**Athly Front** · React Native + Expo · Offline-first +Athly Front : React Native, Expo, PWA. Local first pour l'entraînement, backend pour le social.
From c8a15ad14c7ca003c3c303562a30dbe44a989677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Mon, 13 Jul 2026 15:25:29 +0200 Subject: [PATCH 29/31] Fix settings --- front/src/screens/Profile/SettingsScreen.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index d3326fa..c29b1b0 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -56,8 +56,8 @@ const SEP = 'rgba(255,255,255,0.07)'; const GOLD_BG = 'rgba(255,215,0,0.06)'; const GOLD_BDR = 'rgba(255,215,0,0.22)'; -// ─── Tap trigger to reveal dev section : 10 taps ───────────────────────────── -const DEV_TAP_TARGET = 10; +// ─── Tap trigger to reveal dev section : 20 taps ───────────────────────────── +const DEV_TAP_TARGET = 20; export default function SettingsScreen({ navigation }) { const { signOut } = useAuth(); From 84c911abddf57f8c2c7b3b7591034e6fe7cfdbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Wed, 15 Jul 2026 10:25:36 +0200 Subject: [PATCH 30/31] fix(balance): recalibrage de la courbe de progression XP d'amitie (niveau 5 cible a 4 mois) --- back/controllers/groupStreak.controller.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/back/controllers/groupStreak.controller.js b/back/controllers/groupStreak.controller.js index b185c80..a2f43c5 100644 --- a/back/controllers/groupStreak.controller.js +++ b/back/controllers/groupStreak.controller.js @@ -53,10 +53,11 @@ function startOfToday() { /** * XP d'amitié attribué par validation de streak de groupe. - * Palier : +10 XP de base, +10 tous les 7 jours de streak consécutive. + * Palier : +5 XP de base, +5 tous les 30 jours de streak consécutive + * (environ 4 mois de streak ininterrompue pour atteindre le niveau 5 maximum). */ function computeGroupFriendshipXpGain(currentStreak) { - return 10 * (Math.floor(currentStreak / 7) + 1); + return 5 * (Math.floor((currentStreak - 1) / 30) + 1); } /** From 98e43f28bd2bd6b8a5343428b1d411e7636c3f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9mentin=20Ly?= Date: Wed, 15 Jul 2026 10:34:54 +0200 Subject: [PATCH 31/31] feat(social): integration des titres d'amitie du niveau 1 a 5 avec affichage dynamique et mise a jour du tutoriel --- .../social/FriendshipLevelUpModal.js | 8 ++++++++ front/src/data/friendshipTitles.js | 18 ++++++++++++++++++ front/src/data/tutorialChapters.js | 6 ++++++ .../src/screens/Social/FriendProfileScreen.js | 4 ++++ front/src/screens/Social/SocialScreen.js | 7 +++++-- 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 front/src/data/friendshipTitles.js diff --git a/front/src/components/social/FriendshipLevelUpModal.js b/front/src/components/social/FriendshipLevelUpModal.js index 67cd3ef..d52ba91 100644 --- a/front/src/components/social/FriendshipLevelUpModal.js +++ b/front/src/components/social/FriendshipLevelUpModal.js @@ -3,6 +3,7 @@ import { View, Text, StyleSheet, Modal, TouchableOpacity, Animated } from 'react import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import BirthdayConfetti from '../profile/BirthdayConfetti'; +import { getFriendshipTitle } from '../../data/friendshipTitles'; // Progression de couleur vers le Rouge Sang du niveau 5 ("Lien de Sang") — // même esprit que RANK_COLORS dans LevelUpModal.js, mais indexé par niveau @@ -29,6 +30,7 @@ const FRIENDSHIP_LEVEL_COLORS = { export default function FriendshipLevelUpModal({ visible, pseudo, level, onClose }) { const color = FRIENDSHIP_LEVEL_COLORS[level] || FRIENDSHIP_LEVEL_COLORS[1]; const isMax = level >= 5; + const friendshipTitle = getFriendshipTitle(level); const badgeScale = useRef(new Animated.Value(0)).current; useEffect(() => { @@ -56,6 +58,7 @@ export default function FriendshipLevelUpModal({ visible, pseudo, level, onClose NIVEAU D'AMITIÉ Niveau {level}/5 + {friendshipTitle.label} avec {pseudo} @@ -118,6 +121,11 @@ const styles = StyleSheet.create({ fontWeight: '800', letterSpacing: -0.5, }, + title: { + fontSize: 13, + fontWeight: '700', + marginTop: 6, + }, rank: { color: Colors.textSecondary, fontSize: 14, diff --git a/front/src/data/friendshipTitles.js b/front/src/data/friendshipTitles.js new file mode 100644 index 0000000..1618f79 --- /dev/null +++ b/front/src/data/friendshipTitles.js @@ -0,0 +1,18 @@ +// Titres de Relation (Titres d'Amitié) — cosmétique textuel affiché sous le +// niveau d'amitié entre deux amis. Purement déclaratif côté front : le niveau +// d'amitié (1-5) est calculé par le backend (voir groupStreak.controller.js +// → computeFriendshipLevel), ce fichier ne fait que le traduire en libellé. + +export const FRIENDSHIP_TITLES = { + 1: { label: 'Nouveaux Partenaires' }, + 2: { label: 'Assisteurs de Confiance' }, + 3: { label: 'Frères de Fonte' }, + 4: { label: 'Rivaux Légendaires' }, + 5: { label: 'Âmes Sœurs de Muscle' }, +}; + +/** Renvoie { label } pour un niveau d'amitié donné (1-5, clampé). */ +export function getFriendshipTitle(level) { + const clamped = Math.min(5, Math.max(1, Number(level) || 1)); + return FRIENDSHIP_TITLES[clamped]; +} diff --git a/front/src/data/tutorialChapters.js b/front/src/data/tutorialChapters.js index 839a35f..8250797 100644 --- a/front/src/data/tutorialChapters.js +++ b/front/src/data/tutorialChapters.js @@ -259,6 +259,12 @@ export const TUTORIAL_CHAPTERS = [ body: "Si la streak de groupe casse, le dernier Briseur s'affiche dans le Hall of Shame. Le bouton 'Secouer' envoie une notif troll aux retardataires pour les motiver.", targetKey: null, position: 'center', scrollY: null, }, + { + key: 'social_friendship_titles', + title: "Titres d'Amitié", + body: "Chaque jour de streak de groupe validé à plusieurs renforce ton lien avec chaque coéquipier. De 'Nouveaux Partenaires' à 'Âmes Sœurs de Muscle' au niveau 5 maximum, un Titre d'Amitié affiche l'histoire que vous avez construite ensemble.", + targetKey: null, position: 'center', scrollY: null, + }, { key: 'social_multi', title: 'Le Mode Multi', diff --git a/front/src/screens/Social/FriendProfileScreen.js b/front/src/screens/Social/FriendProfileScreen.js index 6e738fb..9eb8ab4 100644 --- a/front/src/screens/Social/FriendProfileScreen.js +++ b/front/src/screens/Social/FriendProfileScreen.js @@ -19,6 +19,7 @@ import AchievementShowcase from '../../components/profile/AchievementShowcase'; import FriendShowcaseGrid from '../../components/profile/FriendShowcaseGrid'; import PersonalRecordsList from '../../components/profile/PersonalRecordsList'; import { resolveExerciseMeta } from '../../data/majorExercises'; +import { getFriendshipTitle } from '../../data/friendshipTitles'; // ─── FriendProfileScreen ────────────────────────────────────────────────────── // Profil public d'un ami (Brique III) : miroir en lecture seule de notre propre @@ -111,6 +112,7 @@ export default function FriendProfileScreen({ route, navigation }) { const level = useMemo(() => xpToLevel(profile?.user?.xp ?? 0).level, [profile]); const rank = useMemo(() => getRank(level), [level]); + const friendshipTitle = useMemo(() => getFriendshipTitle(profile?.friendshipLevel), [profile]); const isElite = level >= 91; const isLegend = level >= 171; const isGod = level >= 200; @@ -205,6 +207,7 @@ export default function FriendProfileScreen({ route, navigation }) { Niveau d'amitié {profile.friendshipLevel}/5 {profile.friendshipLevel === 5 ? ' · Lien de Sang' : ''} + {friendshipTitle.label} {profile.friendshipXp} XP d'amitié
@@ -293,6 +296,7 @@ const styles = StyleSheet.create({ }, friendshipHearts: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 }, friendshipLevel: { color: Colors.textPrimary, fontSize: 13.5, fontWeight: '700' }, + friendshipTitle: { color: Colors.primary, fontSize: 12, fontWeight: '700', marginTop: 4 }, friendshipXp: { color: Colors.textMuted, fontSize: 11.5, marginTop: 2 }, shakeBanner: { diff --git a/front/src/screens/Social/SocialScreen.js b/front/src/screens/Social/SocialScreen.js index ac12fd4..60fbe5b 100644 --- a/front/src/screens/Social/SocialScreen.js +++ b/front/src/screens/Social/SocialScreen.js @@ -20,6 +20,7 @@ import { getMyGroup, inviteToGroup, respondToGroupInvite, shakeMember, checkGroupStreak, leaveGroup, } from '../../services'; import { MAJOR_EXERCISES } from '../../data/majorExercises'; +import { getFriendshipTitle } from '../../data/friendshipTitles'; import ExercisePickerModal from '../../components/social/ExercisePickerModal'; import TutorialOverlay from '../../components/tutorial/TutorialOverlay'; import { useTutorial, useTutorialTarget } from '../../context/TutorialContext'; @@ -419,7 +420,7 @@ function FriendsSegment({ ) : friends.map((f, i) => ( onOpenProfile(f.user, f.friendshipLevel)}> - + onRemoveFriend(f.friendshipId, f.user.pseudo)} @@ -885,7 +886,7 @@ const WEATHER_META = { sleeping: { icon: 'moon', color: Colors.textMuted, label: 'En sommeil' }, }; -function UserRow({ user, children }) { +function UserRow({ user, subtitle, children }) { const weather = user?.weatherStatus ? WEATHER_META[user.weatherStatus] : null; return ( @@ -903,6 +904,7 @@ function UserRow({ user, children }) { Nv. {user?.level ?? 1} · {user?.rank ?? 'Novice'} {weather ? ` · ${weather.label}` : ''}
+ {subtitle ? {subtitle} : null}
{children}
@@ -1007,6 +1009,7 @@ const styles = StyleSheet.create({ }, userPseudo: { color: Colors.textPrimary, fontSize: 14, fontWeight: '700' }, userMeta: { color: Colors.textMuted, fontSize: 11.5, marginTop: 1 }, + userFriendshipTitle: { color: Colors.primary, fontSize: 10.5, fontWeight: '700', marginTop: 2 }, userActions:{ flexDirection: 'row', alignItems: 'center', gap: 6 }, hearts: { flexDirection: 'row', alignItems: 'center' },