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