Sfoglia il codice sorgente

feat/step 3 (#6)

* docs(README): update contributor images to use GitHub avatars for consistency and improved appearance

* feat(workout-builder): implement exercise fetching based on selected muscles and equipment to enhance workout customization
feat(migrations): convert text columns to enums in exercise attribute tables for better data integrity and performance
feat(migrations): add missing enum values for exercise attributes to ensure comprehensive exercise categorization
docs(README): update contributor section to use a dynamic contributor graph for better visibility and engagement

* feat(locales): add muscle and exercise translations in English and French for improved localization
feat(workout-builder): implement ExerciseCard and ExerciseListItem components for better exercise management in the workout builder
feat(workout-stepper): enhance workout stepper with exercise selection, shuffling, and adding functionality for a more interactive user experience

* refactor(exercise-list-item.tsx): improve muscle badge styling and structure for better readability and maintainability, and enhance hover effects for user interaction

* style(exercise-list-item.tsx): simplify className logic by removing conditional styles for picked state to enhance readability and maintainability

* feat(exercise-list-item): add exercise image display and play icon overlay to enhance user experience
style(exercise-list-item): improve tooltip and badge styling for better visual consistency and interaction feedback

* feat(exercise-list-item.tsx): add locale support for exercise names and improve accessibility by using localized names in alt attributes

* feat(get-exercises.action.ts): add exercise randomization and improve error messages for better user experience

- Implement a shuffle function to randomize exercise selection.
- Increase the number of exercises fetched to allow for better randomization.
- Update error messages to be more user-friendly and in English.

* feat(locales): enhance no_exercises_found message for better user guidance in both English and French
feat(get-exercises.action.ts): improve exercise selection logic by adding secondary muscle support and weighted randomization for better variety
fix(workout-stepper.tsx): adjust navigation logic to ensure continuity based on picked exercises and simplify footer rendering logic
Mat B. 1 mese fa
parent
commit
afa87ffc70

+ 3 - 3
README.md

@@ -17,9 +17,9 @@
 
 ## Contributors
 
-[![snouzy_biceps](https://avatars.githubusercontent.com/u/32961176?s=52&v=4)](https://twitter.com/snouzy_biceps)
-[![lucaasnp_](https://avatars.githubusercontent.com/u/44783073?s=52&v=4)](https://twitter.com/lucaasnp_)
-
+<a href="https://github.com/Snouzy/workout-cool/graphs/contributors">
+  <img src="https://contrib.rocks/image?repo=Snouzy/workout-cool" />
+</a>
 ## About
 
 A comprehensive fitness coaching platform that allows create workout plans for you, track progress, and access a vast exercise database with

File diff suppressed because it is too large
+ 7 - 2
data/sample-exercises.csv


+ 28 - 0
locales/en.ts

@@ -45,6 +45,34 @@ export default {
         description: "Customize your workout",
       },
     },
+    muscles: {
+      abdominals: "Abdominals",
+      back: "Back",
+      biceps: "Biceps",
+      triceps: "Triceps",
+      chest: "Chest",
+      shoulders: "Shoulders",
+      quadriceps: "Quadriceps",
+      hamstrings: "Hamstrings",
+      glutes: "Glutes",
+      calves: "Calves",
+      forearms: "Forearms",
+      traps: "Traps",
+      obliques: "Obliques",
+    },
+    exercise: {
+      watch_video: "Watch video",
+      shuffle: "Shuffle",
+      pick: "Pick",
+      remove: "Remove",
+    },
+    loading: {
+      exercises: "Loading exercises...",
+    },
+    error: {
+      loading_exercises: "Error loading exercises",
+    },
+    no_exercises_found: "No exercises found. Try to change your equipment or muscles selection.",
     equipment: {
       bodyweight: {
         label: "Bodyweight",

+ 29 - 1
locales/fr.ts

@@ -45,6 +45,34 @@ export default {
         description: "Personnalisez votre séance",
       },
     },
+    muscles: {
+      abdominals: "Abdominaux",
+      back: "Dos",
+      biceps: "Biceps",
+      triceps: "Triceps",
+      chest: "Pectoraux",
+      shoulders: "Épaules",
+      quadriceps: "Quadriceps",
+      hamstrings: "Ischio-jambiers",
+      glutes: "Fessiers",
+      calves: "Mollets",
+      forearms: "Avant-bras",
+      traps: "Trapèzes",
+      obliques: "Obliques",
+    },
+    exercise: {
+      watch_video: "Voir la vidéo",
+      shuffle: "Mélanger",
+      pick: "Choisir",
+      remove: "Supprimer",
+    },
+    loading: {
+      exercises: "Chargement des exercices...",
+    },
+    error: {
+      loading_exercises: "Erreur lors du chargement des exercices",
+    },
+    no_exercises_found: "Aucun exercice trouvé. Essayez de changer vos équipements ou vos muscles sélectionnés.",
     equipment: {
       bodyweight: {
         label: "Poids du corps",
@@ -102,7 +130,7 @@ export default {
       select_equipment_description: "Sélectionnez l'équipement pour débloquer des entraînements personnalisés",
       clear_all: "Tout effacer",
       muscle_selection_coming_soon: "Sélection des muscles (Bientôt disponible)",
-      muscle_selection_description: "Cette étape vous permettra de sélectionner les muscles cibles pour votre entraînement.",
+      muscle_selection_description: "Sélectionnez le(s) muscle(s) que vous voulez entraîner en cliquant dessus.",
       exercise_selection_coming_soon: "Sélection des exercices (Bientôt disponible)",
       exercise_selection_description: "Cette étape vous montrera des recommandations d'exercices personnalisées.",
     },

+ 39 - 0
prisma/migrations/20250611190228_convert_text_to_enums/migration.sql

@@ -0,0 +1,39 @@
+/*
+  Warnings:
+
+  - Changed the type of `name` on the `exercise_attribute_names` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
+  - Changed the type of `value` on the `exercise_attribute_values` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
+
+*/
+
+-- Safe migration for exercise_attribute_names
+-- 1. Add temporary column with enum type
+ALTER TABLE "exercise_attribute_names" ADD COLUMN "name_temp" "ExerciseAttributeNameEnum";
+
+-- 2. Migrate data from text to enum (cast text to enum)
+UPDATE "exercise_attribute_names" SET "name_temp" = "name"::"ExerciseAttributeNameEnum";
+
+-- 3. Drop old column and rename temp column
+ALTER TABLE "exercise_attribute_names" DROP COLUMN "name";
+ALTER TABLE "exercise_attribute_names" RENAME COLUMN "name_temp" TO "name";
+
+-- 4. Set NOT NULL constraint
+ALTER TABLE "exercise_attribute_names" ALTER COLUMN "name" SET NOT NULL;
+
+-- Safe migration for exercise_attribute_values  
+-- 1. Add temporary column with enum type
+ALTER TABLE "exercise_attribute_values" ADD COLUMN "value_temp" "ExerciseAttributeValueEnum";
+
+-- 2. Migrate data from text to enum (cast text to enum)
+UPDATE "exercise_attribute_values" SET "value_temp" = "value"::"ExerciseAttributeValueEnum";
+
+-- 3. Drop old column and rename temp column
+ALTER TABLE "exercise_attribute_values" DROP COLUMN "value";
+ALTER TABLE "exercise_attribute_values" RENAME COLUMN "value_temp" TO "value";
+
+-- 4. Set NOT NULL constraint
+ALTER TABLE "exercise_attribute_values" ALTER COLUMN "value" SET NOT NULL;
+
+-- Recreate indexes
+CREATE UNIQUE INDEX "exercise_attribute_names_name_key" ON "exercise_attribute_names"("name");
+CREATE UNIQUE INDEX "exercise_attribute_values_attributeNameId_value_key" ON "exercise_attribute_values"("attributeNameId", "value");

+ 69 - 0
prisma/migrations/20250611210106_add_enum_values/migration.sql

@@ -0,0 +1,69 @@
+/*
+  Add missing values to enums to prepare for schema alignment
+*/
+
+-- Add missing values to ExerciseAttributeValueEnum
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'BODYWEIGHT';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'POWERLIFTING';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'CALISTHENIC';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'STRETCHING';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'STRONGMAN';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'STABILIZATION';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'POWER';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'RESISTANCE';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'WEIGHTLIFTING';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'BICEPS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'CHEST';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'BACK';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'TRICEPS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'CALVES';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'TRAPS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'ABDOMINALS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'NECK';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'LATS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'ADDUCTORS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'ABDUCTORS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'OBLIQUES';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'GROIN';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'ROTATOR_CUFF';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'HIP_FLEXOR';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'ACHILLES_TENDON';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'FINGERS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'DUMBBELL';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'KETTLEBELLS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'SMITH_MACHINE';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'BODY_ONLY';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'OTHER';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'BANDS';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'EZ_BAR';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'MACHINE';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'DESK';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'PULLUP_BAR';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'NONE';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'MEDICINE_BALL';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'SWISS_BALL';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'FOAM_ROLL';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'WEIGHT_PLATE';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'TRX';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'BOX';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'ROPES';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'SPIN_BIKE';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'STEP';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'BOSU';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'TYRE';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'SANDBAG';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'POLE';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'WALL';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'RACK';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'CAR';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'SLED';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'CHAIN';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'SKIERG';
+ALTER TYPE "ExerciseAttributeValueEnum" ADD VALUE IF NOT EXISTS 'NA';
+
+-- Add missing values to ExerciseAttributeNameEnum (if needed)
+ALTER TYPE "ExerciseAttributeNameEnum" ADD VALUE IF NOT EXISTS 'TYPE';
+ALTER TYPE "ExerciseAttributeNameEnum" ADD VALUE IF NOT EXISTS 'PRIMARY_MUSCLE';
+ALTER TYPE "ExerciseAttributeNameEnum" ADD VALUE IF NOT EXISTS 'SECONDARY_MUSCLE';
+ALTER TYPE "ExerciseAttributeNameEnum" ADD VALUE IF NOT EXISTS 'EQUIPMENT';
+ALTER TYPE "ExerciseAttributeNameEnum" ADD VALUE IF NOT EXISTS 'MECHANICS_TYPE'; 

+ 206 - 0
src/features/workout-builder/model/get-exercises.action.ts

@@ -0,0 +1,206 @@
+"use server";
+
+import { ExerciseAttributeNameEnum } from "@prisma/client";
+
+import { prisma } from "@/shared/lib/prisma";
+import { actionClient } from "@/shared/api/safe-actions";
+
+import { getExercisesSchema } from "../schema/get-exercises.schema";
+
+// Utility function to shuffle an array (Fisher-Yates shuffle)
+function shuffleArray<T>(array: T[]): T[] {
+  const shuffled = [...array];
+  for (let i = shuffled.length - 1; i > 0; i--) {
+    const j = Math.floor(Math.random() * (i + 1));
+    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+  }
+  return shuffled;
+}
+
+export const getExercisesAction = actionClient.schema(getExercisesSchema).action(async ({ parsedInput }) => {
+  const { equipment, muscles, limit } = parsedInput;
+
+  try {
+    // First, get the attribute name IDs once
+    const [primaryMuscleAttributeName, secondaryMuscleAttributeName, equipmentAttributeName] = await Promise.all([
+      prisma.exerciseAttributeName.findUnique({
+        where: { name: ExerciseAttributeNameEnum.PRIMARY_MUSCLE },
+      }),
+      prisma.exerciseAttributeName.findUnique({
+        where: { name: ExerciseAttributeNameEnum.SECONDARY_MUSCLE },
+      }),
+      prisma.exerciseAttributeName.findUnique({
+        where: { name: ExerciseAttributeNameEnum.EQUIPMENT },
+      }),
+    ]);
+
+    if (!primaryMuscleAttributeName || !secondaryMuscleAttributeName || !equipmentAttributeName) {
+      throw new Error("Missing attributes in database");
+    }
+
+    // Get exercises for each selected muscle using Hybrid Algorithm
+    const exercisesByMuscle = await Promise.all(
+      muscles.map(async (muscle) => {
+        const MINIMUM_THRESHOLD = 20;
+        const TARGET_POOL_SIZE = Math.max(limit * 4, 30); // Larger pool for better randomization
+
+        // Step 1: Get exercises where muscle is PRIMARY
+        const primaryExercises = await prisma.exercise.findMany({
+          where: {
+            AND: [
+              {
+                attributes: {
+                  some: {
+                    attributeNameId: primaryMuscleAttributeName.id,
+                    attributeValue: {
+                      value: muscle,
+                    },
+                  },
+                },
+              },
+              {
+                attributes: {
+                  some: {
+                    attributeNameId: equipmentAttributeName.id,
+                    attributeValue: {
+                      value: {
+                        in: equipment,
+                      },
+                    },
+                  },
+                },
+              },
+              // Exclude stretching exercises
+              {
+                NOT: {
+                  attributes: {
+                    some: {
+                      attributeValue: {
+                        value: "STRETCHING",
+                      },
+                    },
+                  },
+                },
+              },
+            ],
+          },
+          include: {
+            attributes: {
+              include: {
+                attributeName: true,
+                attributeValue: true,
+              },
+            },
+          },
+          take: TARGET_POOL_SIZE,
+        });
+
+        let allExercises = [...primaryExercises];
+
+        // Step 2: If we don't have enough exercises, add SECONDARY muscle exercises
+        if (allExercises.length < MINIMUM_THRESHOLD) {
+          const secondaryExercises = await prisma.exercise.findMany({
+            where: {
+              AND: [
+                {
+                  attributes: {
+                    some: {
+                      attributeNameId: secondaryMuscleAttributeName.id,
+                      attributeValue: {
+                        value: muscle,
+                      },
+                    },
+                  },
+                },
+                {
+                  attributes: {
+                    some: {
+                      attributeNameId: equipmentAttributeName.id,
+                      attributeValue: {
+                        value: {
+                          in: equipment,
+                        },
+                      },
+                    },
+                  },
+                },
+                // Exclude exercises already found as primary
+                {
+                  id: {
+                    notIn: primaryExercises.map((ex) => ex.id),
+                  },
+                },
+                // Exclude stretching exercises
+                {
+                  NOT: {
+                    attributes: {
+                      some: {
+                        attributeValue: {
+                          value: "STRETCHING",
+                        },
+                      },
+                    },
+                  },
+                },
+              ],
+            },
+            include: {
+              attributes: {
+                include: {
+                  attributeName: true,
+                  attributeValue: true,
+                },
+              },
+            },
+            take: TARGET_POOL_SIZE - primaryExercises.length,
+          });
+
+          allExercises = [...allExercises, ...secondaryExercises];
+        }
+
+        // Step 3: Weighted randomization (favor primary muscle exercises)
+        const shuffledPrimary = shuffleArray(primaryExercises);
+        const shuffledSecondary = shuffleArray(allExercises.filter((ex) => !primaryExercises.some((primary) => primary.id === ex.id)));
+
+        // Step 4: Create final selection with weighted distribution
+        const selectedExercises = [];
+        const primaryRatio = 0.7; // 70% primary muscles when possible
+        const targetPrimary = Math.ceil(limit * primaryRatio);
+        const targetSecondary = limit - targetPrimary;
+
+        // Add primary muscle exercises first
+        selectedExercises.push(...shuffledPrimary.slice(0, Math.min(targetPrimary, shuffledPrimary.length)));
+
+        // Fill remaining slots with secondary or more primary exercises
+        const remainingSlots = limit - selectedExercises.length;
+        if (remainingSlots > 0) {
+          if (shuffledSecondary.length > 0) {
+            selectedExercises.push(...shuffledSecondary.slice(0, Math.min(targetSecondary, shuffledSecondary.length)));
+          }
+
+          // If still need more exercises, add more primary ones
+          const stillNeedMore = limit - selectedExercises.length;
+          if (stillNeedMore > 0 && shuffledPrimary.length > targetPrimary) {
+            selectedExercises.push(...shuffledPrimary.slice(targetPrimary, targetPrimary + stillNeedMore));
+          }
+        }
+
+        // Final shuffle to avoid predictable patterns
+        const finalExercises = shuffleArray(selectedExercises).slice(0, limit);
+
+        return {
+          muscle,
+          exercises: finalExercises,
+        };
+      }),
+    );
+
+    // Filter muscles that have no exercises
+    const filteredResults = exercisesByMuscle.filter((group) => group.exercises.length > 0);
+
+    return filteredResults;
+  } catch (error) {
+    console.error("Error fetching exercises:", error);
+    throw new Error("Error fetching exercises");
+  }
+});

+ 37 - 0
src/features/workout-builder/model/use-exercises.ts

@@ -0,0 +1,37 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { ExerciseAttributeValueEnum } from "@prisma/client";
+
+import { getExercisesAction } from "./get-exercises.action";
+
+interface UseExercisesProps {
+  equipment: ExerciseAttributeValueEnum[];
+  muscles: ExerciseAttributeValueEnum[];
+  enabled?: boolean;
+}
+
+export function useExercises({ equipment, muscles, enabled = true }: UseExercisesProps) {
+  return useQuery({
+    queryKey: ["exercises", equipment.sort(), muscles.sort()],
+    queryFn: async () => {
+      if (equipment.length === 0 || muscles.length === 0) {
+        return [];
+      }
+
+      const result = await getExercisesAction({
+        equipment,
+        muscles,
+        limit: 3,
+      });
+
+      if (result?.serverError) {
+        throw new Error(result.serverError);
+      }
+
+      return result?.data || [];
+    },
+    enabled: enabled && equipment.length > 0 && muscles.length > 0,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+  });
+}

+ 17 - 0
src/features/workout-builder/model/use-workout-stepper.ts

@@ -5,6 +5,7 @@ import { useQueryState, parseAsInteger, parseAsArrayOf, parseAsString } from "nu
 import { ExerciseAttributeValueEnum } from "@prisma/client";
 
 import { WorkoutBuilderStep } from "../types";
+import { useExercises } from "./use-exercises";
 
 export function useWorkoutStepper() {
   // État persistant dans l'URL avec nuqs
@@ -14,6 +15,17 @@ export function useWorkoutStepper() {
 
   const [selectedMuscles, setSelectedMuscles] = useQueryState("muscles", parseAsArrayOf(parseAsString).withDefault([]));
 
+  // Récupération des exercices
+  const {
+    data: exercisesByMuscle = [],
+    isLoading: isLoadingExercises,
+    error: exercisesError,
+  } = useExercises({
+    equipment: selectedEquipment as ExerciseAttributeValueEnum[],
+    muscles: selectedMuscles as ExerciseAttributeValueEnum[],
+    enabled: currentStep === 3, // Récupérer seulement à l'étape 3
+  });
+
   // Navigation entre les étapes
   const goToStep = useCallback(
     (step: WorkoutBuilderStep) => {
@@ -81,6 +93,11 @@ export function useWorkoutStepper() {
     selectedEquipment: selectedEquipment as ExerciseAttributeValueEnum[],
     selectedMuscles: selectedMuscles as ExerciseAttributeValueEnum[],
 
+    // Exercices
+    exercisesByMuscle,
+    isLoadingExercises,
+    exercisesError,
+
     // Navigation
     goToStep,
     nextStep,

+ 10 - 0
src/features/workout-builder/schema/get-exercises.schema.ts

@@ -0,0 +1,10 @@
+import { z } from "zod";
+import { ExerciseAttributeValueEnum } from "@prisma/client";
+
+export const getExercisesSchema = z.object({
+  equipment: z.array(z.nativeEnum(ExerciseAttributeValueEnum)).min(1, "Au moins un équipement est requis"),
+  muscles: z.array(z.nativeEnum(ExerciseAttributeValueEnum)).min(1, "Au moins un muscle est requis"),
+  limit: z.number().int().min(1).max(10).default(3),
+});
+
+export type GetExercisesInput = z.infer<typeof getExercisesSchema>;

+ 14 - 1
src/features/workout-builder/types/index.ts

@@ -1,5 +1,5 @@
 import { StaticImageData } from "next/image";
-import { ExerciseAttributeValueEnum } from "@prisma/client";
+import { ExerciseAttributeValueEnum, Exercise, ExerciseAttribute, ExerciseAttributeName, ExerciseAttributeValue } from "@prisma/client";
 
 export interface WorkoutBuilderState {
   currentStep: number;
@@ -25,3 +25,16 @@ export interface EquipmentItem {
   description?: string;
   className?: string;
 }
+
+// Types pour les exercices avec leurs attributs
+export type ExerciseWithAttributes = Exercise & {
+  attributes: (ExerciseAttribute & {
+    attributeName: ExerciseAttributeName;
+    attributeValue: ExerciseAttributeValue;
+  })[];
+};
+
+export interface ExercisesByMuscle {
+  muscle: ExerciseAttributeValueEnum;
+  exercises: ExerciseWithAttributes[];
+}

+ 171 - 0
src/features/workout-builder/ui/exercise-card.tsx

@@ -0,0 +1,171 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import { Play, Shuffle, MoreVertical, Trash2, Info, Target } from "lucide-react";
+
+import { useI18n } from "locales/client";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+import type { ExerciseWithAttributes } from "../types";
+
+interface ExerciseCardProps {
+  exercise: ExerciseWithAttributes;
+  muscle: string;
+  onShuffle: (exerciseId: string, muscle: string) => void;
+  onPick: (exerciseId: string) => void;
+  onDelete: (exerciseId: string, muscle: string) => void;
+}
+
+export function ExerciseCard({ exercise, muscle, onShuffle, onPick, onDelete }: ExerciseCardProps) {
+  const t = useI18n();
+  const [imageError, setImageError] = useState(false);
+
+  // Extraire les attributs utiles
+  const equipmentAttributes =
+    exercise.attributes?.filter((attr) => attr.attributeName.name === "EQUIPMENT").map((attr) => attr.attributeValue.value) || [];
+
+  const typeAttributes =
+    exercise.attributes?.filter((attr) => attr.attributeName.name === "TYPE").map((attr) => attr.attributeValue.value) || [];
+
+  const mechanicsType = exercise.attributes?.find((attr) => attr.attributeName.name === "MECHANICS_TYPE")?.attributeValue.value;
+
+  return (
+    <TooltipProvider>
+      <Card className="group relative overflow-hidden bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:shadow-lg transition-all duration-200 hover:border-blue-200 dark:hover:border-blue-800">
+        <CardHeader className="relative p-0">
+          {/* Image/Vidéo thumbnail */}
+          <div className="relative h-48 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-800">
+            {exercise.fullVideoImageUrl && !imageError ? (
+              <>
+                <Image
+                  alt={exercise.name}
+                  className="object-cover transition-transform group-hover:scale-105"
+                  fill
+                  onError={() => setImageError(true)}
+                  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
+                  src={exercise.fullVideoImageUrl}
+                />
+                <div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
+                  <Button className="bg-white/90 text-slate-900" size="small" variant="secondary">
+                    <Play className="h-4 w-4 mr-2" />
+                    {t("workout_builder.exercise.watch_video")}
+                  </Button>
+                </div>
+              </>
+            ) : (
+              <div className="h-full flex items-center justify-center">
+                <div className="text-slate-400 dark:text-slate-500">
+                  <Target className="h-12 w-12" />
+                </div>
+              </div>
+            )}
+
+            {/* Badge du muscle en haut à gauche */}
+            <div className="absolute top-3 left-3">
+              <Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100" variant="outline">
+                {t(`workout_builder.muscles.${muscle.toLowerCase()}` as keyof typeof t)}
+              </Badge>
+            </div>
+
+            {/* Menu d'actions en haut à droite */}
+            <div className="absolute top-3 right-3">
+              <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                  <Button className="h-8 w-8 bg-white/90 hover:bg-white" size="small" variant="ghost">
+                    <MoreVertical className="h-4 w-4" />
+                  </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="end">
+                  <DropdownMenuItem onClick={() => onShuffle(exercise.id, muscle)}>
+                    <Shuffle className="h-4 w-4 mr-2" />
+                    {t("workout_builder.exercise.shuffle")}
+                  </DropdownMenuItem>
+                  <DropdownMenuItem onClick={() => onDelete(exercise.id, muscle)}>
+                    <Trash2 className="h-4 w-4 mr-2" />
+                    {t("workout_builder.exercise.remove")}
+                  </DropdownMenuItem>
+                </DropdownMenuContent>
+              </DropdownMenu>
+            </div>
+          </div>
+        </CardHeader>
+
+        <CardContent className="p-4">
+          {/* Titre de l'exercice */}
+          <div className="flex items-start justify-between mb-3">
+            <h4 className="font-semibold text-slate-900 dark:text-slate-100 text-sm leading-tight line-clamp-2">{exercise.name}</h4>
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <Button className="h-8 w-8 ml-2 flex-shrink-0" size="small" variant="ghost">
+                  <Info className="h-4 w-4" />
+                </Button>
+              </TooltipTrigger>
+              <TooltipContent className="max-w-xs" side="left">
+                <div className="space-y-2">
+                  <p className="text-sm">{exercise.introduction}</p>
+                  {mechanicsType && (
+                    <p className="text-xs text-slate-500">
+                      <strong>Type:</strong> {mechanicsType}
+                    </p>
+                  )}
+                </div>
+              </TooltipContent>
+            </Tooltip>
+          </div>
+
+          {/* Tags des équipements */}
+          {equipmentAttributes.length > 0 && (
+            <div className="flex flex-wrap gap-1 mb-3">
+              {equipmentAttributes.slice(0, 2).map((equipment, index) => (
+                <Badge className="text-xs px-2 py-0.5" key={index} variant="outline">
+                  {equipment.replace("_", " ")}
+                </Badge>
+              ))}
+              {equipmentAttributes.length > 2 && (
+                <Badge className="text-xs px-2 py-0.5" variant="outline">
+                  +{equipmentAttributes.length - 2}
+                </Badge>
+              )}
+            </div>
+          )}
+
+          {/* Types d'entraînement */}
+          {typeAttributes.length > 0 && (
+            <div className="flex flex-wrap gap-1 mb-4">
+              {typeAttributes.slice(0, 2).map((type, index) => (
+                <Badge
+                  className="text-xs px-2 py-0.5 bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100"
+                  key={index}
+                  variant="default"
+                >
+                  {type}
+                </Badge>
+              ))}
+            </div>
+          )}
+
+          {/* Actions */}
+          <div className="flex items-center gap-2">
+            <Button
+              className="flex-1 text-blue-600 border-blue-200 hover:bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:hover:bg-blue-950"
+              onClick={() => onShuffle(exercise.id, muscle)}
+              size="small"
+              variant="outline"
+            >
+              <Shuffle className="h-4 w-4 mr-1" />
+              {t("workout_builder.exercise.shuffle")}
+            </Button>
+            <Button className="flex-1 bg-blue-600 hover:bg-blue-700 text-white" onClick={() => onPick(exercise.id)} size="small">
+              ⭐ {t("workout_builder.exercise.pick")}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </TooltipProvider>
+  );
+}

+ 136 - 0
src/features/workout-builder/ui/exercise-list-item.tsx

@@ -0,0 +1,136 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import { Play, Shuffle, Star, Trash2, GripVertical } from "lucide-react";
+
+import { useCurrentLocale, useI18n } from "locales/client";
+import { InlineTooltip } from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+
+import type { ExerciseWithAttributes } from "../types";
+
+interface ExerciseListItemProps {
+  exercise: ExerciseWithAttributes;
+  muscle: string;
+  onShuffle: (exerciseId: string, muscle: string) => void;
+  onPick: (exerciseId: string) => void;
+  onDelete: (exerciseId: string, muscle: string) => void;
+  isPicked?: boolean;
+}
+
+export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete, isPicked = false }: ExerciseListItemProps) {
+  const t = useI18n();
+  const [isHovered, setIsHovered] = useState(false);
+  const locale = useCurrentLocale();
+  const exerciseName = locale === "fr" ? exercise.name : exercise.nameEn;
+
+  // Déterminer la couleur du muscle
+  const getMuscleConfig = (muscle: string) => {
+    const configs: Record<string, { color: string; bg: string }> = {
+      ABDOMINALS: { color: "text-red-600 dark:text-red-400", bg: "bg-red-50 dark:bg-red-950/50" },
+      BICEPS: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-50 dark:bg-purple-950/50" },
+      BACK: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-50 dark:bg-blue-950/50" },
+      CHEST: { color: "text-green-600 dark:text-green-400", bg: "bg-green-50 dark:bg-green-950/50" },
+      SHOULDERS: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-50 dark:bg-orange-950/50" },
+      OBLIQUES: { color: "text-pink-600 dark:text-pink-400", bg: "bg-pink-50 dark:bg-pink-950/50" },
+    };
+    return configs[muscle] || { color: "text-slate-600 dark:text-slate-400", bg: "bg-slate-50 dark:bg-slate-950/50" };
+  };
+
+  const muscleConfig = getMuscleConfig(muscle);
+
+  return (
+    <div
+      className={`
+        group relative overflow-hidden transition-all duration-300 ease-out
+        bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800/70
+        border-b border-slate-200 dark:border-slate-700/50
+        ${isHovered ? "shadow-lg shadow-slate-200/50 dark:shadow-slate-900/50" : ""}
+      `}
+      onMouseEnter={() => setIsHovered(true)}
+      onMouseLeave={() => setIsHovered(false)}
+    >
+      <div className="relative flex items-center justify-between py-2 px-2">
+        {/* Section gauche - Infos principales */}
+        <div className="flex items-center gap-4 flex-1 min-w-0">
+          {/* Drag handle */}
+          <GripVertical className="h-5 w-5 text-slate-400 dark:text-slate-500 cursor-grab active:cursor-grabbing" />
+
+          {/* Image de l'exercice */}
+          {exercise.fullVideoImageUrl && (
+            <div className="relative w-10 h-10 rounded-lg overflow-hidden shrink-0 bg-slate-100 dark:bg-slate-800 cursor-pointer border border-slate-200 dark:border-slate-700/50">
+              <Image
+                alt={exerciseName ?? ""}
+                className="w-full h-full object-cover scale-[1.5]"
+                height={40}
+                onError={(e) => {
+                  // Fallback si l'image ne charge pas
+                  e.currentTarget.style.display = "none";
+                }}
+                src={exercise.fullVideoImageUrl}
+                width={40}
+              />
+              {/* Overlay play icon */}
+              <div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
+                <Play className="h-3 w-3 text-white fill-current" />
+              </div>
+            </div>
+          )}
+
+          {/* Badge muscle avec animation */}
+          <InlineTooltip className="cursor-pointer" title={t(("workout_builder.muscles." + muscle.toLowerCase()) as keyof typeof t)}>
+            <div
+              className={`
+            relative flex items-center justify-center w-5 h-5 rounded-sm font-bold text-xs shrink-0
+            ${muscleConfig.bg} ${muscleConfig.color}
+            transition-all duration-200 
+            cursor-pointer
+          `}
+            >
+              {muscle.charAt(0)}
+            </div>
+          </InlineTooltip>
+
+          {/* Nom de l'exercice avec indicateurs */}
+          <div className="flex-1 min-w-0">
+            <div className="flex items-center gap-3 mb-1">
+              <h3 className="font-semibold text-slate-900 dark:text-slate-100 truncate text-sm">{exerciseName}</h3>
+            </div>
+          </div>
+        </div>
+
+        {/* Section droite - Actions */}
+        <div className="flex items-center gap-2 shrink-0">
+          {/* Bouton shuffle */}
+          <Button onClick={() => onShuffle(exercise.id, muscle)} size="small" variant="outline">
+            <Shuffle />
+            <span className="hidden sm:inline">{t("workout_builder.exercise.shuffle")}</span>
+          </Button>
+
+          {/* Bouton pick */}
+          <Button
+            className={
+              "bg-blue-50 dark:bg-blue-950/50 hover:bg-blue-100 dark:hover:bg-blue-950 text-blue-600 dark:text-blue-400 border-2 border-blue-200 dark:border-blue-800"
+            }
+            onClick={() => onPick(exercise.id)}
+            size="small"
+          >
+            <Star />
+            <span className="hidden sm:inline">{t("workout_builder.exercise.pick")}</span>
+          </Button>
+
+          {/* Bouton delete */}
+          <Button
+            className="h-9 w-9 bg-red-50 dark:bg-red-950/50 hover:bg-red-100 dark:hover:bg-red-950 text-red-600 dark:text-red-400 border-0 rounded-lg  group-hover:opacity-100 transition-all duration-200 hover:scale-110"
+            onClick={() => onDelete(exercise.id, muscle)}
+            size="small"
+            variant="ghost"
+          >
+            <Trash2 className="h-3.5 w-3.5" />
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 92 - 8
src/features/workout-builder/ui/workout-stepper.tsx

@@ -1,6 +1,7 @@
 "use client";
 
-import { ArrowLeft, ArrowRight, CheckCircle, Zap } from "lucide-react";
+import { useState } from "react";
+import { ArrowLeft, ArrowRight, CheckCircle, Zap, Plus } from "lucide-react";
 
 import { useI18n } from "locales/client";
 import { Button } from "@/components/ui/button";
@@ -9,6 +10,7 @@ import { StepperStepProps } from "../types";
 import { useWorkoutStepper } from "../model/use-workout-stepper";
 import { StepperHeader } from "./stepper-header";
 import { MuscleSelection } from "./muscle-selection";
+import { ExerciseListItem } from "./exercise-list-item";
 import { EquipmentSelection } from "./equipment-selection";
 
 function NavigationFooter({
@@ -47,7 +49,7 @@ function NavigationFooter({
                 </span>
               </div>
             )}
-            {currentStep !== 1 && (
+            {currentStep === 2 && (
               <div className="flex items-center gap-2 text-sm">
                 <CheckCircle className="h-4 w-4 text-blue-500" />
                 <span className="font-medium text-slate-700 dark:text-slate-300">
@@ -107,7 +109,7 @@ function NavigationFooter({
               </span>
             </div>
           )}
-          {currentStep !== 1 && (
+          {currentStep === 2 && (
             <div className="flex items-center gap-2 text-sm">
               <CheckCircle className="h-4 w-4 text-blue-500" />
               <span className="font-medium text-slate-700 dark:text-slate-300">
@@ -150,10 +152,36 @@ export function WorkoutStepper() {
     toggleMuscle,
     canProceedToStep2,
     canProceedToStep3,
+    isLoadingExercises,
+    exercisesByMuscle,
+    exercisesError,
   } = useWorkoutStepper();
 
+  // État pour les exercices sélectionnés (picked)
+  const [pickedExercises, setPickedExercises] = useState<string[]>([]);
+
   // Calculer si on peut continuer selon l'étape
-  const canContinue = currentStep === 1 ? canProceedToStep2 : currentStep === 2 ? canProceedToStep3 : false;
+  const canContinue = currentStep === 1 ? canProceedToStep2 : currentStep === 2 ? canProceedToStep3 : pickedExercises.length > 0;
+
+  // Actions pour les exercices
+  const handleShuffleExercise = (exerciseId: string, muscle: string) => {
+    // TODO: Implémenter la logique pour remplacer l'exercice par un autre
+    console.log("Shuffle exercise:", exerciseId, "for muscle:", muscle);
+  };
+
+  const handlePickExercise = (exerciseId: string) => {
+    setPickedExercises((prev) => (prev.includes(exerciseId) ? prev.filter((id) => id !== exerciseId) : [...prev, exerciseId]));
+  };
+
+  const handleDeleteExercise = (exerciseId: string, muscle: string) => {
+    // TODO: Implémenter la logique pour supprimer l'exercice
+    console.log("Delete exercise:", exerciseId, "for muscle:", muscle);
+  };
+
+  const handleAddExercise = () => {
+    // TODO: Implémenter la logique pour ajouter un exercice
+    console.log("Add exercise");
+  };
 
   // Calculer l'état des étapes avec traductions
   const STEPPER_STEPS: StepperStepProps[] = [
@@ -197,9 +225,65 @@ export function WorkoutStepper() {
         return <MuscleSelection onToggleMuscle={toggleMuscle} selectedEquipment={selectedEquipment} selectedMuscles={selectedMuscles} />;
       case 3:
         return (
-          <div className="text-center py-20">
-            <h3 className="text-xl font-semibold mb-4">{t("workout_builder.coming_soon.exercises")}</h3>
-            <p className="text-slate-600 dark:text-slate-400">{t("workout_builder.coming_soon.exercises_description")}</p>
+          <div className="space-y-6">
+            {isLoadingExercises ? (
+              <div className="text-center py-20">
+                <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
+                <p className="mt-4 text-slate-600 dark:text-slate-400">{t("workout_builder.loading.exercises")}</p>
+              </div>
+            ) : exercisesByMuscle.length > 0 ? (
+              <div className="max-w-4xl mx-auto">
+                {/* Liste des exercices */}
+                <div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
+                  {exercisesByMuscle.map((group, groupIndex) => (
+                    <div key={group.muscle}>
+                      {group.exercises.map((exercise, exerciseIndex) => (
+                        <ExerciseListItem
+                          exercise={exercise}
+                          isPicked={pickedExercises.includes(exercise.id)}
+                          key={exercise.id}
+                          muscle={group.muscle}
+                          onDelete={handleDeleteExercise}
+                          onPick={handlePickExercise}
+                          onShuffle={handleShuffleExercise}
+                        />
+                      ))}
+                    </div>
+                  ))}
+
+                  {/* Add exercise button */}
+                  <div className="border-t border-slate-200 dark:border-slate-800">
+                    <button
+                      className="w-full flex items-center gap-3 py-4 px-4 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors"
+                      onClick={handleAddExercise}
+                    >
+                      <div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center">
+                        <Plus className="h-4 w-4 text-white" />
+                      </div>
+                      <span className="font-medium">Add</span>
+                    </button>
+                  </div>
+                </div>
+
+                {/* Bottom actions */}
+                <div className="flex items-center justify-center gap-4 mt-8">
+                  <Button className="px-8" size="large" variant="outline">
+                    Save for later
+                  </Button>
+                  <Button className="px-8 bg-blue-600 hover:bg-blue-700" size="large">
+                    Start Workout
+                  </Button>
+                </div>
+              </div>
+            ) : exercisesError ? (
+              <div className="text-center py-20">
+                <p className="text-red-600 dark:text-red-400">{t("workout_builder.error.loading_exercises")}</p>
+              </div>
+            ) : (
+              <div className="text-center py-20">
+                <p className="text-slate-600 dark:text-slate-400">{t("workout_builder.no_exercises_found")}</p>
+              </div>
+            )}
           </div>
         );
       default:
@@ -208,7 +292,7 @@ export function WorkoutStepper() {
   };
 
   return (
-    <div className="w-full max-w-4xl mx-auto">
+    <div className="w-full max-w-6xl mx-auto">
       {/* En-tête du stepper */}
       <StepperHeader steps={steps} />
 

Some files were not shown because too many files changed in this diff