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; +}