workout-session-sets.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. "use client";
  2. import { useState } from "react";
  3. import { useRouter } from "next/navigation";
  4. import Image from "next/image";
  5. import { Check, Hourglass, Play, ArrowRight, Trophy as TrophyIcon, Plus } 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. if (showCongrats) {
  33. return (
  34. <div className="flex flex-col items-center justify-center py-16">
  35. <Image alt={t("workout_builder.session.complete") + " trophy"} className="w-56 h-56" src={TrophyImg} />
  36. <h2 className="text-2xl font-bold mb-2">{t("workout_builder.session.complete") + " ! 🎉"}</h2>
  37. <p className="text-lg text-slate-600 mb-6">{t("workout_builder.session.workout_in_progress")}</p>
  38. <Button onClick={() => router.push("/profile")}>{t("commons.go_to_profile")}</Button>
  39. </div>
  40. );
  41. }
  42. if (!session) {
  43. return <div className="text-center text-slate-500 py-12">{t("workout_builder.session.no_exercise_selected")}</div>;
  44. }
  45. const handleExerciseClick = (targetIndex: number) => {
  46. if (targetIndex !== currentExerciseIndex) {
  47. goToExercise(targetIndex);
  48. }
  49. };
  50. const renderStepIcon = (idx: number, allSetsCompleted: boolean) => {
  51. if (allSetsCompleted) {
  52. return <Check aria-label="Exercice terminé" className="w-4 h-4 text-white" />;
  53. }
  54. if (idx === currentExerciseIndex) {
  55. return <Hourglass aria-label="Exercice en cours" className="w-4 h-4 text-white" />;
  56. }
  57. return null;
  58. };
  59. const renderStepBackground = (idx: number, allSetsCompleted: boolean) => {
  60. if (allSetsCompleted) {
  61. return "bg-green-500 border-green-500";
  62. }
  63. if (idx === currentExerciseIndex) {
  64. return "bg-blue-500 border-blue-500";
  65. }
  66. return "bg-slate-200 border-slate-200";
  67. };
  68. const handleFinishSession = () => {
  69. completeWorkout();
  70. syncSessions();
  71. onCongrats();
  72. confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
  73. };
  74. return (
  75. <div className="w-full max-w-3xl mx-auto pb-8">
  76. <ol className="relative border-l-2 ml-2 border-slate-200 dark:border-slate-700">
  77. {session.exercises.map((ex, idx) => {
  78. const allSetsCompleted = ex.sets.length > 0 && ex.sets.every((set) => set.completed);
  79. const exerciseName = locale === "fr" ? ex.name : ex.nameEn;
  80. const details = exerciseDetailsMap[ex.id];
  81. return (
  82. <li
  83. className={`mb-8 ml-4 ${idx !== currentExerciseIndex ? "cursor-pointer hover:opacity-80" : ""}`}
  84. key={ex.id}
  85. onClick={() => handleExerciseClick(idx)}
  86. >
  87. {/* Cercle étape */}
  88. <span
  89. className={cn(
  90. "absolute -left-4 flex items-center justify-center w-8 h-8 rounded-full border-4 z-10",
  91. renderStepBackground(idx, allSetsCompleted),
  92. )}
  93. >
  94. {renderStepIcon(idx, allSetsCompleted)}
  95. </span>
  96. {/* Image + nom de l'exercice */}
  97. <div className="flex items-center gap-3 ml-2 hover:opacity-80">
  98. {details?.fullVideoImageUrl && (
  99. <div
  100. className="relative aspect-video max-w-24 rounded-lg overflow-hidden shrink-0 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700/50 cursor-pointer"
  101. onClick={(e) => {
  102. e.stopPropagation();
  103. setVideoModal({ open: true, exerciseId: ex.id });
  104. }}
  105. >
  106. <Image
  107. alt={exerciseName || "Exercise image"}
  108. className="w-full h-full object-cover scale-[1.35]"
  109. height={48}
  110. src={details.fullVideoImageUrl}
  111. width={48}
  112. />
  113. <div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity duration-200">
  114. <Button className="bg-white/80" size="icon" variant="ghost">
  115. <Play className="h-4 w-4 text-blue-600" />
  116. </Button>
  117. </div>
  118. </div>
  119. )}
  120. <div
  121. className={cn(
  122. "text-xl",
  123. idx === currentExerciseIndex
  124. ? "font-bold text-blue-600"
  125. : "text-slate-700 dark:text-slate-300 transition-colors hover:text-blue-500",
  126. )}
  127. >
  128. {exerciseName}
  129. {details?.introduction && (
  130. <span
  131. className="block text-xs mt-1 text-slate-500 dark:text-slate-400 underline cursor-pointer hover:text-blue-600"
  132. onClick={(e) => {
  133. e.stopPropagation();
  134. setVideoModal({ open: true, exerciseId: ex.id });
  135. }}
  136. >
  137. {t("workout_builder.session.see_instructions")}
  138. </span>
  139. )}
  140. {/* Fallback: description si pas d'introduction */}
  141. </div>
  142. </div>
  143. {/* Modale vidéo */}
  144. {details && details.fullVideoUrl && videoModal.open && videoModal.exerciseId === ex.id && (
  145. <ExerciseVideoModal
  146. exercise={details}
  147. onOpenChange={(open) => setVideoModal({ open, exerciseId: open ? ex.id : undefined })}
  148. open={videoModal.open}
  149. />
  150. )}
  151. {/* Si exercice courant, afficher le détail */}
  152. {idx === currentExerciseIndex && (
  153. <div className="bg-white dark:bg-transparent rounded-xl my-10">
  154. {/* Liste des sets */}
  155. <div className="space-y-10 mb-8">
  156. {ex.sets.map((set, setIdx) => (
  157. <WorkoutSessionSet
  158. key={set.id}
  159. onChange={(sIdx: number, data: Partial<typeof set>) => updateSet(idx, sIdx, data)}
  160. onFinish={() => finishSet(idx, setIdx)}
  161. onRemove={() => removeSet(idx, setIdx)}
  162. set={set}
  163. setIndex={setIdx}
  164. />
  165. ))}
  166. </div>
  167. {/* Actions bas de page */}
  168. <div className="flex flex-col md:flex-row gap-3 w-full mt-2 px-2">
  169. <Button
  170. aria-label="Ajouter une série"
  171. 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"
  172. onClick={addSet}
  173. >
  174. <Plus className="h-5 w-5" />
  175. {t("workout_builder.session.add_set")}
  176. </Button>
  177. <Button
  178. aria-label="Exercice suivant"
  179. 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"
  180. onClick={goToNextExercise}
  181. >
  182. <ArrowRight className="h-5 w-5" />
  183. {t("workout_builder.session.next_exercise")}
  184. </Button>
  185. </div>
  186. </div>
  187. )}
  188. </li>
  189. );
  190. })}
  191. </ol>
  192. {isWorkoutActive && (
  193. <div className="flex justify-center mt-8">
  194. <Button
  195. aria-label="Terminer la séance"
  196. 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"
  197. onClick={handleFinishSession}
  198. >
  199. <TrophyIcon className="h-6 w-6" />
  200. {t("workout_builder.session.finish_session")}
  201. </Button>
  202. </div>
  203. )}
  204. </div>
  205. );
  206. }