Ver código fonte

feat(locales): add Spanish language support by including translations and updating locale handling (#58)

fix(middleware): update locales array to include Spanish for proper internationalization
fix(workout-session-sets): prevent auto-scrolling to current exercise when adding sets
feat(language-selector): enhance language selection to support Spanish with appropriate flags and labels
Mat B. 1 mês atrás
pai
commit
df52a01373

+ 5 - 1
locales/client.ts

@@ -3,7 +3,7 @@
 import { createI18nClient } from "next-international/client";
 
 // NOTE: Also update middleware.ts to support locale
-export const languages = ["en", "fr"];
+export const languages = ["en", "fr", "es"];
 
 export const { useI18n, useScopedI18n, I18nProviderClient, useChangeLocale, defineLocale, useCurrentLocale } = createI18nClient(
   {
@@ -15,6 +15,10 @@ export const { useI18n, useScopedI18n, I18nProviderClient, useChangeLocale, defi
       await new Promise((resolve) => setTimeout(resolve, 100));
       return import("./fr");
     },
+    es: async () => {
+      await new Promise((resolve) => setTimeout(resolve, 100));
+      return import("./es");
+    },
   },
   {
     // Uncomment to set base path

+ 421 - 0
locales/es.ts

@@ -0,0 +1,421 @@
+export default {
+  email_sent: "Email enviado",
+  cant_send_email: "No se puede enviar el email",
+  logout: "Cerrar sesión",
+  verify_email: "Verificar tu email",
+  verify_email_subtitle: "Por favor verifica tu email para continuar.",
+  resend_email: "Reenviar email",
+  resend_email_countdown: "Reenviar email en {seconds} segundos",
+  signin_error_subtitle: "Por favor verifica tus credenciales e intenta de nuevo.",
+  register_title: "Crear una cuenta",
+  register_description: "Ingresa tu información para crear tu cuenta",
+  register_terms: "Al registrarte, aceptas nuestros",
+  register_privacy: "Política de privacidad",
+  register_privacy_link: "y nuestra",
+  register_privacy_link_2: "Política de privacidad",
+  password_forgot_title: "¿Olvidaste tu contraseña?",
+  password_forgot_subtitle: "Ingresa tu email para restablecer tu contraseña",
+  new_password: "Nueva contraseña",
+  new_password_placeholder: "Ingresa tu nueva contraseña",
+  current_password: "Contraseña actual",
+  current_password_placeholder: "Ingresa tu contraseña actual",
+  confirm_password: "Confirmar contraseña",
+  confirm_password_placeholder: "Confirma tu contraseña",
+
+  success: {
+    feedback_sent: "Comentario enviado",
+    password_forgot_success: "Email enviado",
+    reset_password_success: "Contraseña restablecida exitosamente",
+    password_updated_successfully: "Contraseña actualizada exitosamente",
+  },
+
+  error: {
+    invalid_credentials: "Credenciales inválidas o cuenta inexistente",
+    upload_failed: "Error al subir",
+    generic_error: "Error durante la operación",
+    sending_email: "Error al enviar el email",
+  },
+
+  backend_errors: {
+    EMAIL_ALREADY_EXISTS: "Email ya existente",
+    INVALID_FILE_TYPE: "Tipo de archivo inválido",
+    FILE_TOO_LARGE: "Archivo demasiado grande",
+    NO_FILE_UPLOADED: "Ningún archivo subido",
+    IMAGE_PROCESSING_ERROR: "Error al procesar la imagen",
+    upload_failed: "Error al subir",
+  },
+
+  profile: {
+    new_workout: "Nuevo entrenamiento",
+    alert: {
+      title: "Tu progreso está almacenado en tu navegador.",
+      create_account: "Crear una cuenta",
+      log_in: "Iniciar sesión",
+      to_ensure_it_is_not_getting_lost: "para asegurar que no se pierda.",
+    },
+  },
+
+  // Release Notes
+  release_notes: {
+    title: "Novedades",
+    release_notes: "Notas",
+    notes: {
+      note_2025_06_19: {
+        title: "📱 ¡Ahora disponible como PWA!",
+        content:
+          "¡Workout.cool v1.2 ahora es una Progressive Web App! Instálala en tu teléfono para una experiencia de aplicación nativa con acceso sin conexión. 🚀",
+      },
+      note_2025_06_18: {
+        title: "🚀 ¡Destacado #1 en Hacker News!",
+        content:
+          "¡Workout.cool alcanzó el primer lugar en Hacker News! ¡Gracias a todos por el increíble apoyo y bienvenidos a todos los nuevos usuarios! 💪",
+      },
+      note_2025_06_01: {
+        title: "🎉 Nuevo: Diálogo de notas de versión",
+        content: "¡Ahora puedes ver las novedades directamente desde la cabecera! Mantente atento para más actualizaciones.",
+      },
+      note_2025_05_20: {
+        title: "Mejoras de la interfaz",
+        content: "Mejora de la responsividad móvil y adición de efectos de hover sutiles a los botones.",
+      },
+    },
+  },
+
+  // Contact Support
+  contact_support: "Contactar soporte",
+  contact_support_subtitle: "Describe tu problema y te ayudaremos lo antes posible. También puedes escribirnos directamente a",
+
+  // Social Platforms
+  social_platforms: {
+    x: "X (Twitter)",
+    facebook: "Facebook",
+    email: "Email",
+    whatsapp: "WhatsApp",
+    website: "Sitio web",
+    phone: "Teléfono",
+    youtube: "YouTube",
+    linkedin: "LinkedIn",
+    snapchat: "Snapchat",
+    instagram: "Instagram",
+    tiktok: "TikTok",
+    threads: "Threads",
+  },
+
+  // Workout Builder
+  workout_builder: {
+    confirm_delete: "¿Estás seguro de que quieres eliminar esta sesión de entrenamiento?",
+    steps: {
+      equipment: {
+        title: "Equipo",
+        description: "Selecciona tu equipo",
+      },
+      muscles: {
+        title: "Músculos",
+        description: "Elige tu entrenamiento",
+      },
+      exercises: {
+        title: "Ejercicios",
+        description: "Personaliza tu sesión",
+      },
+    },
+    muscles: {
+      abdominals: "Abdominales",
+      back: "Espalda",
+      biceps: "Bíceps",
+      triceps: "Tríceps",
+      chest: "Pecho",
+      shoulders: "Hombros",
+      quadriceps: "Cuádriceps",
+      hamstrings: "Isquiotibiales",
+      glutes: "Glúteos",
+      calves: "Pantorrillas",
+      forearms: "Antebrazos",
+      traps: "Trapecios",
+      obliques: "Oblicuos",
+    },
+    exercise: {
+      watch_video: "Ver video",
+      shuffle: "Mezclar",
+      pick: "Elegir",
+      remove: "Eliminar",
+      no_video_available: "No hay video disponible.",
+    },
+    loading: {
+      exercises: "Cargando ejercicios...",
+    },
+    error: {
+      loading_exercises: "Error al cargar ejercicios",
+    },
+    no_exercises_found: "No se encontraron ejercicios. Intenta cambiar tu selección de equipos o músculos.",
+    equipment: {
+      bodyweight: {
+        label: "Peso corporal",
+        description: "Ejercicios usando únicamente el peso de tu cuerpo",
+      },
+      dumbbell: {
+        label: "Mancuernas",
+        description: "Ejercicios de peso libre con mancuernas",
+      },
+      barbell: {
+        label: "Barra",
+        description: "Movimientos compuestos con una barra",
+      },
+      kettlebell: {
+        label: "Kettlebell",
+        description: "Ejercicios dinámicos con kettlebells",
+      },
+      band: {
+        label: "Banda elástica",
+        description: "Ejercicios con bandas de resistencia",
+      },
+      plate: {
+        label: "Discos",
+        description: "Ejercicios usando discos de peso",
+      },
+      pullup_bar: {
+        label: "Barra de dominadas",
+        description: "Ejercicios del tren superior con barra de dominadas",
+      },
+      bench: {
+        label: "Banco",
+        description: "Ejercicios en banco y soporte",
+      },
+    },
+    navigation: {
+      previous: "Anterior",
+      continue: "Continuar",
+      complete: "Completar",
+    },
+    stats: {
+      "muscle_selected#zero": "0 músculos seleccionados",
+      "muscle_selected#one": "1 músculo seleccionado",
+      "muscle_selected#other": "{count} músculos seleccionados",
+      "equipment_selected#zero": "0 equipos seleccionados",
+      "equipment_selected#one": "1 equipo seleccionado",
+      "equipment_selected#other": "{count} equipos seleccionados",
+      selected: "Seleccionado",
+      total: "Total",
+      equipment_ready: "equipo listo",
+      equipment_ready_plural: "equipos listos",
+    },
+    selection: {
+      choose_your_arsenal: "Elige tu arsenal",
+      select_equipment_description: "Selecciona el equipo para desbloquear entrenamientos personalizados",
+      clear_all: "Limpiar todo",
+      muscle_selection_coming_soon: "Selección de músculos (Próximamente)",
+      muscle_selection_description: "Selecciona el/los músculo(s) que quieres entrenar haciendo clic en ellos.",
+      exercise_selection_coming_soon: "Selección de ejercicios (Próximamente)",
+      exercise_selection_description: "Este paso te mostrará recomendaciones de ejercicios personalizadas.",
+    },
+    session: {
+      back_to_workout: "Volver al entrenamiento",
+      congrats: "¡Felicidades, entrenamiento terminado! 🎉",
+      congrats_subtitle: "¡Lo has logrado!",
+      see_instructions: "Ver instrucciones",
+      finish_set: "Terminar serie",
+      finish_session: "Terminar sesión",
+      bodyweight: "Peso corporal",
+      weight: "Peso",
+      reps: "Repeticiones",
+      time: "Tiempo",
+      next_exercise: "Siguiente ejercicio",
+      add_set: "Agregar serie",
+      add_column: "Agregar columna",
+      add_row: "Agregar fila de atributos",
+      remove_column: "Eliminar columna",
+      set_number: "Serie {number}",
+      set_number_plural: "Series {number}",
+      set_number_singular: "Serie {number}",
+      set_number_plural_singular: "Series {number}",
+      workout_in_progress: "Entrenamiento en progreso",
+      started_at: "Iniciado a las",
+      quit_workout: "Abandonar entrenamiento",
+      elapsed_time: "Tiempo transcurrido",
+      chronometer: "Cronómetro",
+      total_workout_time: "Tiempo total de entrenamiento",
+      exercise_progress: "Progreso",
+      total_volume: "Volumen Total",
+      current_exercise: "Ejercicio actual",
+      complete: "Completo",
+      active: "Activo",
+      already_have_a_active_session: "Ya tienes una sesión activa. Imposible repetir sin terminar o abandonar el entrenamiento.",
+      no_exercise_selected: "Ningún ejercicio seleccionado",
+      quit_workout_title: "¿Abandonar entrenamiento?",
+      progress: "Progreso",
+      quit_warning: "¿Estás seguro de que quieres abandonar? Puedes guardar tu progreso o perderlo completamente.",
+      save_and_quit: "Guardar y abandonar",
+      quit_without_save: "Abandonar sin guardar",
+      continue_workout: "Continuar entrenamiento",
+      history: "Historial de entrenamientos [{count}]",
+      no_workout_yet: "Ningún entrenamiento aún.",
+      start: "inicio",
+      end: "fin",
+      exercise: "EJERCICIO",
+      repeat: "Repetir",
+      delete: "Eliminar",
+    },
+    attribute_value: {
+      bodyweight: "Peso corporal",
+      strength: "Fuerza",
+      powerlifting: "Powerlifting",
+      calisthenic: "Calistenia",
+      plyometrics: "Pliometría",
+      stretching: "Estiramiento",
+      strongman: "Strongman",
+      cardio: "Cardio",
+      stabilization: "Estabilización",
+      power: "Potencia",
+      resistance: "Resistencia",
+      crossfit: "CrossFit",
+      weightlifting: "Halterofilia",
+      neck: "Cuello",
+      lats: "Dorsales",
+      adductors: "Aductores",
+      abductors: "Abductores",
+      groin: "Ingle",
+      full_body: "Cuerpo completo",
+      rotator_cuff: "Manguito rotador",
+      hip_flexor: "Flexor de cadera",
+      achilles_tendon: "Tendón de Aquiles",
+      fingers: "Dedos",
+      smith_machine: "Máquina Smith",
+      other: "Otro",
+      ez_bar: "Barra EZ",
+      machine: "Máquina",
+      desk: "Escritorio",
+      none: "Ninguno",
+      cable: "Cable",
+      medicine_ball: "Pelota medicinal",
+      swiss_ball: "Pelota suiza",
+      foam_roll: "Rodillo de espuma",
+      trx: "TRX",
+      box: "Cajón",
+      ropes: "Cuerdas",
+      spin_bike: "Bicicleta de spinning",
+      step: "Step",
+      bosu: "BOSU",
+      tyre: "Neumático",
+      sandbag: "Saco de arena",
+      pole: "Barra vertical",
+      wall: "Pared",
+      bar: "Barra",
+      rack: "Rack",
+      car: "Coche",
+      sled: "Trineo",
+      chain: "Cadena",
+      skierg: "SkiErg",
+      rope: "Cuerda",
+      na: "N/A",
+      isolation: "Aislamiento",
+      compound: "Compuesto",
+    },
+  },
+  commons: {
+    signup_with: "Registrarse con {provider}",
+    signin_with: "Iniciar sesión con {provider}",
+    signup: "Registrarse",
+    login: "Iniciar sesión",
+    connecting: "Conectando...",
+    password_reset_success: "Contraseña restablecida exitosamente",
+    login_to_your_account_title: "Inicia sesión en tu cuenta",
+    login_to_your_account_subtitle: "Ingresa tus credenciales para iniciar sesión",
+    password_forgot: "¿Olvidaste tu contraseña?",
+    dont_have_account: "¿No tienes una cuenta?",
+    already_have_account: "¿Ya tienes una cuenta?",
+    or: "O",
+    add: "Agregar",
+    your_feminine: "tu",
+    password: "Contraseña",
+    email: "Email",
+    logout: "Cerrar sesión",
+    first_name: "Nombre",
+    last_name: "Apellido",
+    verify_password: "Verificar contraseña",
+    submit: "Enviar",
+    upload: "Subir",
+    cancel: "Cancelar",
+    save_changes: "Guardar cambios",
+    change: "Cambiar",
+    subject: "Asunto",
+    message: "Mensaje",
+    saving: "Guardando...",
+    edit: "Editar",
+    more_options: "Más opciones",
+    open_link: "Abrir enlace",
+    hide: "Ocultar",
+    make_visible: "Hacer visible",
+    delete: "Eliminar",
+    share: "Compartir",
+    title: "Título",
+    subtitle: "Subtítulo",
+    content: "Contenido",
+    save: "Guardar",
+    button: "Botón",
+    card: "Tarjeta",
+    go_back: "Volver",
+    next: "Siguiente",
+    choose_image: "Elegir imagen",
+    soon: "Pronto",
+    coming_soon_with_emoji: "Próximamente 🤫",
+    no_image: "Sin imagen",
+    description: "Descripción",
+    price: "Precio",
+    duration: "Duración",
+    location: "Ubicación",
+    schedule: "Horario",
+    participants_info: "Información de participantes",
+    title_placeholder: "Ingresa el título",
+    description_placeholder: "Ingresa la descripción",
+    changes_saved: "Cambios guardados",
+    replace: "Reemplazar",
+    loading: "Cargando...",
+    image_deleted: "La imagen ha sido eliminada",
+    discover_workoutcool: "Descubre gratis",
+    received_just_now: "Recibido ahora",
+    copied: "Copiado",
+    url_copied: "La URL ha sido copiada",
+    copy_failed: "Error al copiar la URL",
+    accordion: "Acordeón",
+    image: "Imagen",
+    other: "Otro",
+    register: "Registrarse",
+    instantly: "instantáneamente",
+    immediately: "inmediatamente",
+    link: "Enlace",
+    accept: "Aceptar",
+    deny: "Denegar",
+    invalid_input: "Entrada inválida. Por favor verifica los errores.",
+    copy_url: "Copiar URL",
+    page_url: "URL de la página",
+    saving_short: "Guardando...",
+    saved_short: "Guardado",
+    looks_like_you_are_lost: "Parece que estás perdido",
+    the_page_you_are_looking_for_is_not_available: "La página que buscas no está disponible",
+    go_to_home: "Ir al inicio",
+    go_to_profile: "Ir a mi perfil",
+    terms: "Términos de uso",
+    privacy: "Política de privacidad",
+    sales_terms: "Términos de venta",
+    consent_banner: "Usamos cookies para mejorar tu experiencia. Al hacer clic en Aceptar, aceptas nuestras cookies.",
+    about: "Acerca de",
+    profile: "Perfil",
+    donate: "Donar",
+    my_account: "Mi cuenta",
+    dashboard: "Panel",
+    home: "Inicio",
+    changelog: "Anuncios y notas de versión",
+    stop_impersonation_button: "Detener suplantación",
+    impersonating_user_label: "Suplantando usuario",
+    re_hello: "Hola de nuevo",
+    back_to_login: "Volver al inicio de sesión",
+    sending: "Enviando...",
+    send_me_link: "Enviarme enlace",
+    extremely_dissatisfied: "Muy insatisfecho",
+    somewhat_dissatisfied: "Insatisfecho",
+    neutral: "Neutral",
+    satisfied: "Satisfecho",
+    support: "Soporte",
+    change_language: "Cambiar idioma",
+    in_progress: "En progreso",
+  },
+} as const;

+ 1 - 0
locales/server.ts

@@ -3,4 +3,5 @@ import { createI18nServer } from "next-international/server";
 export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({
   en: () => import("./en"),
   fr: () => import("./fr"),
+  es: () => import("./es"),
 });

+ 1 - 1
middleware.ts

@@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from "next/server";
 import { getSessionCookie } from "better-auth/cookies";
 
 const I18nMiddleware = createI18nMiddleware({
-  locales: ["en", "fr"],
+  locales: ["en", "fr", "es"],
   defaultLocale: "en",
   urlMappingStrategy: "rewrite",
 });

+ 5 - 3
src/features/workout-session/ui/workout-session-sets.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
 import { useRouter } from "next/navigation";
 import Image from "next/image";
 import { Check, Play, ArrowRight, Trophy as TrophyIcon, Plus, Hourglass } from "lucide-react";
@@ -33,10 +33,11 @@ export function WorkoutSessionSets({
   const exerciseDetailsMap = Object.fromEntries(session?.exercises.map((ex) => [ex.id, ex]) || []);
   const [videoModal, setVideoModal] = useState<{ open: boolean; exerciseId?: string }>({ open: false });
   const { syncSessions } = useSyncWorkoutSessions();
+  const prevExerciseIndexRef = useRef<number>(currentExerciseIndex);
 
-  // auto-scroll to current exercise when index changes
+  // auto-scroll to current exercise when index changes (but not when adding sets)
   useEffect(() => {
-    if (session && currentExerciseIndex >= 0) {
+    if (session && currentExerciseIndex >= 0 && prevExerciseIndexRef.current !== currentExerciseIndex) {
       const exerciseElement = document.getElementById(`exercise-${currentExerciseIndex}`);
       if (exerciseElement) {
         const scrollContainer = exerciseElement.closest(".overflow-auto");
@@ -59,6 +60,7 @@ export function WorkoutSessionSets({
           });
         }
       }
+      prevExerciseIndexRef.current = currentExerciseIndex;
     }
   }, [currentExerciseIndex, session]);
 

+ 3 - 2
src/widgets/language-selector/language-selector.tsx

@@ -9,6 +9,7 @@ import { updateUserAction } from "@/entities/user/model/update-user.action";
 const languageFlags: Record<string, string> = {
   en: "🇬🇧",
   fr: "🇫🇷",
+  es: "🇪🇸",
 };
 
 export function LanguageSelector() {
@@ -19,7 +20,7 @@ export function LanguageSelector() {
 
   const handleLanguageChange = async (newLocale: string) => {
     await action.execute({ locale: newLocale });
-    changeLocale(newLocale as "en" | "fr");
+    changeLocale(newLocale as "en" | "fr" | "es");
   };
 
   return (
@@ -44,7 +45,7 @@ export function LanguageSelector() {
               onClick={() => handleLanguageChange(language)}
             >
               <span className="text-lg">{languageFlags[language]}</span>
-              <span className="text-base">{language === "en" ? "English" : "Français"}</span>
+              <span className="text-base">{language === "en" ? "English" : language === "fr" ? "Français" : "Español"}</span>
             </button>
           </li>
         ))}