workout-session-sets.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. "use client";
  2. import { useState, useEffect } from "react";
  3. import { useRouter } from "next/navigation";
  4. import Image from "next/image";
  5. import { Check, Play, ArrowRight, Trophy as TrophyIcon, Plus, Hourglass } from "lucide-react";
  6. import confetti from "canvas-confetti";
  7. import { useCurrentLocale, useI18n } from "locales/client";
  8. import TrophyImg from "@public/images/trophy.png";
  9. import { cn } from "@/shared/lib/utils";
  10. import { useWorkoutSession } from "@/features/workout-session/model/use-workout-session";
  11. import { useSyncWorkoutSessions } from "@/features/workout-session/model/use-sync-workout-sessions";
  12. import { ExerciseVideoModal } from "@/features/workout-builder/ui/exercise-video-modal";
  13. import { Button } from "@/components/ui/button";
  14. import { WorkoutSessionSet } from "./workout-session-set";
  15. export function WorkoutSessionSets({
  16. showCongrats,
  17. onCongrats,
  18. isWorkoutActive,
  19. }: {
  20. showCongrats: boolean;
  21. onCongrats: VoidFunction;
  22. isWorkoutActive: boolean;
  23. }) {
  24. const t = useI18n();
  25. const router = useRouter();
  26. const locale = useCurrentLocale();
  27. const { currentExerciseIndex, session, addSet, updateSet, removeSet, finishSet, goToNextExercise, goToExercise, completeWorkout } =
  28. useWorkoutSession();
  29. const exerciseDetailsMap = Object.fromEntries(session?.exercises.map((ex) => [ex.id, ex]) || []);
  30. const [videoModal, setVideoModal] = useState<{ open: boolean; exerciseId?: string }>({ open: false });
  31. const { syncSessions } = useSyncWorkoutSessions();
  32. // auto-scroll to current exercise when index changes
  33. useEffect(() => {
  34. if (session && currentExerciseIndex >= 0) {
  35. const exerciseElement = document.getElementById(`exercise-${currentExerciseIndex}`);
  36. if (exerciseElement) {
  37. const scrollContainer = exerciseElement.closest(".overflow-auto");
  38. if (scrollContainer) {
  39. const containerRect = scrollContainer.getBoundingClientRect();
  40. const elementRect = exerciseElement.getBoundingClientRect();
  41. const offset = 10;
  42. const scrollTop = scrollContainer.scrollTop + elementRect.top - containerRect.top - offset;
  43. scrollContainer.scrollTo({
  44. top: scrollTop,
  45. behavior: "smooth",
  46. });
  47. } else {
  48. exerciseElement.scrollIntoView({
  49. behavior: "smooth",
  50. block: "center",
  51. });
  52. }
  53. }
  54. }
  55. }, [currentExerciseIndex, session]);
  56. if (showCongrats) {
  57. return (
  58. <div className="flex flex-col items-center justify-center py-16 h-full">
  59. <Image alt={t("workout_builder.session.complete") + " trophy"} className="w-56 h-56" src={TrophyImg} />
  60. <h2 className="text-2xl font-bold mb-2">{t("workout_builder.session.complete") + " ! 🎉"}</h2>
  61. <p className="text-lg text-slate-600 mb-6">{t("workout_builder.session.workout_in_progress")}</p>
  62. <Button onClick={() => router.push("/profile")}>{t("commons.go_to_profile")}</Button>
  63. </div>
  64. );
  65. }
  66. if (!session) {
  67. return <div className="text-center text-slate-500 py-12">{t("workout_builder.session.no_exercise_selected")}</div>;
  68. }
  69. const handleExerciseClick = (targetIndex: number) => {
  70. if (targetIndex !== currentExerciseIndex) {
  71. goToExercise(targetIndex);
  72. }
  73. };
  74. const renderStepIcon = (idx: number, allSetsCompleted: boolean) => {
  75. if (allSetsCompleted) {
  76. return <Check aria-label="Exercice terminé" className="w-4 h-4 text-white" />;
  77. }
  78. if (idx === currentExerciseIndex) {
  79. return (
  80. <svg aria-label="Exercice en cours" className="w-8 h-8 animate-ping text-emerald-500" fill="currentColor" viewBox="0 0 24 24">
  81. <circle cx="12" cy="12" r="12" />
  82. </svg>
  83. );
  84. }
  85. return <Hourglass aria-label="Exercice en cours" className="w-4 h-4 text-gray-600 dark:text-slate-900" />;
  86. };
  87. const renderStepBackground = (idx: number, allSetsCompleted: boolean) => {
  88. if (allSetsCompleted) {
  89. return "bg-green-500 border-green-500";
  90. }
  91. if (idx === currentExerciseIndex) {
  92. return "bg-gray-300 border-gray-400 dark:bg-slate-500 dark:border-slate-500";
  93. }
  94. return "bg-slate-200 border-slate-200";
  95. };
  96. const handleFinishSession = () => {
  97. completeWorkout();
  98. syncSessions();
  99. onCongrats();
  100. confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
  101. };
  102. return (
  103. <div className="w-full max-w-3xl mx-auto pb-8 px-3 sm:px-6">
  104. <ol className="relative border-l-2 ml-2 border-slate-200 dark:border-slate-700">
  105. {session.exercises.map((ex, idx) => {
  106. const allSetsCompleted = ex.sets.length > 0 && ex.sets.every((set) => set.completed);
  107. const exerciseName = locale === "fr" ? ex.name : ex.nameEn;
  108. const details = exerciseDetailsMap[ex.id];
  109. return (
  110. <li
  111. className={`mb-8 ml-4 ${idx !== currentExerciseIndex ? "cursor-pointer hover:opacity-80" : ""}`}
  112. id={`exercise-${idx}`}
  113. key={ex.id}
  114. onClick={() => handleExerciseClick(idx)}
  115. >
  116. {/* Cercle étape */}
  117. <span
  118. className={cn(
  119. "absolute -left-4 flex items-center justify-center w-8 h-8 rounded-full border-4 z-10",
  120. renderStepBackground(idx, allSetsCompleted),
  121. )}
  122. >
  123. {renderStepIcon(idx, allSetsCompleted)}
  124. </span>
  125. {/* Image + nom de l'exercice */}
  126. <div className="flex items-center gap-3 ml-2 hover:opacity-80">
  127. {details?.fullVideoImageUrl && (
  128. <div
  129. className="relative aspect-video max-w-24 rounded-lg overflow-hidden shrink-0 bg-slate-200 dark:bg-slate-800 border border-slate-200 dark:border-slate-700/50 cursor-pointer"
  130. onClick={(e) => {
  131. e.stopPropagation();
  132. setVideoModal({ open: true, exerciseId: ex.id });
  133. }}
  134. >
  135. <Image
  136. alt={exerciseName || "Exercise image"}
  137. className="w-full h-full object-cover scale-[1.35]"
  138. height={48}
  139. src={details.fullVideoImageUrl}
  140. width={48}
  141. />
  142. <div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity duration-200">
  143. <Button className="bg-white/80" size="icon" variant="ghost">
  144. <Play className="h-4 w-4 text-blue-600" />
  145. </Button>
  146. </div>
  147. </div>
  148. )}
  149. <div
  150. className={cn(
  151. "text-xl leading-[1.3]",
  152. idx === currentExerciseIndex
  153. ? "font-bold text-blue-600"
  154. : "text-slate-700 dark:text-slate-300 transition-colors hover:text-blue-500",
  155. )}
  156. >
  157. {exerciseName}
  158. {details?.introduction && (
  159. <span
  160. className="block text-xs mt-1 text-slate-500 dark:text-slate-400 underline cursor-pointer hover:text-blue-600"
  161. onClick={(e) => {
  162. e.stopPropagation();
  163. setVideoModal({ open: true, exerciseId: ex.id });
  164. }}
  165. >
  166. {t("workout_builder.session.see_instructions")}
  167. </span>
  168. )}
  169. {/* Fallback: description si pas d'introduction */}
  170. </div>
  171. </div>
  172. {/* Modale vidéo */}
  173. {details && details.fullVideoUrl && videoModal.open && videoModal.exerciseId === ex.id && (
  174. <ExerciseVideoModal
  175. exercise={details}
  176. onOpenChange={(open) => setVideoModal({ open, exerciseId: open ? ex.id : undefined })}
  177. open={videoModal.open}
  178. />
  179. )}
  180. {/* Si exercice courant, afficher le détail */}
  181. {idx === currentExerciseIndex && (
  182. <div className="bg-white dark:bg-transparent rounded-xl my-10">
  183. {/* Liste des sets */}
  184. <div className="space-y-10 mb-8">
  185. {ex.sets.map((set, setIdx) => (
  186. <WorkoutSessionSet
  187. key={set.id}
  188. onChange={(sIdx: number, data: Partial<typeof set>) => updateSet(idx, sIdx, data)}
  189. onFinish={() => finishSet(idx, setIdx)}
  190. onRemove={() => removeSet(idx, setIdx)}
  191. set={set}
  192. setIndex={setIdx}
  193. />
  194. ))}
  195. </div>
  196. {/* Actions bas de page */}
  197. <div className="flex flex-col md:flex-row gap-3 w-full mt-2 px-2">
  198. <Button
  199. aria-label="Ajouter une série"
  200. className="flex-1 flex items-center justify-center gap-2 bg-green-500 hover:bg-green-600 text-white font-bold py-3 rounded-xl border border-green-600 transition-all duration-200 active:scale-95 focus:ring-2 focus:ring-green-400"
  201. onClick={addSet}
  202. >
  203. <Plus className="h-5 w-5" />
  204. {t("workout_builder.session.add_set")}
  205. </Button>
  206. <Button
  207. aria-label="Exercice suivant"
  208. className="flex-1 flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white font-bold py-3 rounded-xl border border-blue-600 transition-all duration-200 active:scale-95 focus:ring-2 focus:ring-blue-400"
  209. onClick={goToNextExercise}
  210. >
  211. <ArrowRight className="h-5 w-5" />
  212. {t("workout_builder.session.next_exercise")}
  213. </Button>
  214. </div>
  215. </div>
  216. )}
  217. </li>
  218. );
  219. })}
  220. </ol>
  221. {isWorkoutActive && (
  222. <div className="flex justify-center mt-8 mb-24">
  223. <Button
  224. aria-label="Terminer la séance"
  225. className="flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white font-bold px-8 py-3 text-lg rounded-2xl border border-green-700 transition-all duration-200 active:scale-95 focus:ring-2 focus:ring-green-400"
  226. onClick={handleFinishSession}
  227. >
  228. <TrophyIcon className="h-6 w-6" />
  229. {t("workout_builder.session.finish_session")}
  230. </Button>
  231. </div>
  232. )}
  233. </div>
  234. );
  235. }