Repository: yaqiin/yaqiin-boycott
Pull request #19: Enhance Language Toggle: Add Dropdown and Support for Multiple Languages
Author: login: oxcodexo
Created: 2025-04-09T11:19:46Z | Merged: 2025-04-09T16:10:50Z

## Description

This PR enhances the language toggle functionality by adding a dropdown menu to support multiple languages. Previously, the platform only supported Arabic and English; now, additional languages have been integrated (fr,es), expanding the platform's accessibility to a broader, global audience. The changes improve both the UI/UX and the underlying code structure to accommodate future language additions easily.

## Type of Change

- [ ] Add/Edit products
- [ ] Bug fix
- [x] New feature
- [x] Code enhancement
- [ ] Documentation update

## Checklist

- [x] I have tested my changes locally
- [x] My code follows the project's coding style



Diff:
diff --git a/src/components/LanguageToggle.tsx b/src/components/LanguageToggle.tsx
index 0e1d26b..bfdb267 100644
--- a/src/components/LanguageToggle.tsx
+++ b/src/components/LanguageToggle.tsx
@@ -1,25 +1,43 @@
 
 import { useLanguage } from "@/contexts/LanguageContext";
 import { Button } from "@/components/ui/button";
-import { Globe } from "lucide-react";
+import { Globe, Check } from "lucide-react";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
 
 const LanguageToggle = () => {
-  const { language, setLanguage, t } = useLanguage();
+  const { language, setLanguage, t, supportedLanguages } = useLanguage();
 
-  const toggleLanguage = () => {
-    setLanguage(language === 'ar' ? 'en' : 'ar');
+  const handleLanguageChange = (newLanguage: string) => {
+    setLanguage(newLanguage as keyof typeof supportedLanguages);
   };
 
   return (
-    <Button
-      onClick={toggleLanguage}
-      variant="outline"
-      className="flex items-center gap-2 rounded-full bg-white/80 backdrop-blur-sm hover:bg-white transition-all"
-      aria-label={t('toggleLanguage')}
-    >
-      <Globe size={16} />
-      <span>{t('toggleLanguage')}</span>
-    </Button>
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button
+          variant="outline"
+          className="flex items-center gap-2 rounded-full bg-white/80 backdrop-blur-sm hover:bg-white transition-all"
+          aria-label={t('toggleLanguage')}
+        >
+          <Globe size={16} />
+          <span>{supportedLanguages[language].nativeName}</span>
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end">
+        {Object.entries(supportedLanguages).map(([code, config]) => (
+          <DropdownMenuItem key={code} onClick={() => handleLanguageChange(code)}>
+            <span className="flex items-center gap-2">
+              {config.nativeName} {language === code && <Check size={16} />}
+            </span>
+          </DropdownMenuItem>
+        ))}
+      </DropdownMenuContent>
+    </DropdownMenu>
   );
 };
 
diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx
index b109090..f878fd0 100644
--- a/src/contexts/LanguageContext.tsx
+++ b/src/contexts/LanguageContext.tsx
@@ -1,72 +1,34 @@
 
 import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
 
-type Language = 'ar' | 'en';
-
-interface LanguageContextType {
-  language: Language;
-  setLanguage: (lang: Language) => void;
-  t: (key: string) => string;
-  isRTL: boolean;
-}
+import { Language, LanguageContextType, SUPPORTED_LANGUAGES, translations, TranslationKey } from './languageConfig';
 
 const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
 
-// Translations object
-const translations = {
-  ar: {
-    'siteName': 'يقين للمقاطعة',
-    'siteSlogan': 'مقاطعة أخلاقية، نصرة لأخوتنا',
-    'toggleLanguage': 'English',
-    'searchPlaceholder': 'ابحث عن منتج',
-    'productName': 'اسم المنتج',
-    'countryOfOrigin': 'بلد المنشأ',
-    'alternatives': 'البدائل',
-    'noAlternatives': 'لا توجد بدائل متاحة حاليًا',
-    'loading': 'جاري التحميل...',
-    'error': 'حدث خطأ في تحميل البيانات',
-    'pageNotFound': 'عذرًا! الصفحة غير موجودة',
-    'backHome': 'العودة إلى الصفحة الرئيسية',
-    'introText': 'منصة تساعدك على اكتشاف بدائل أخلاقية للمنتجات التقنية الأمريكية والإسرائيلية.',
-    'copyright': 'يقين للمقاطعة. جميع الحقوق محفوظة.'
-  },
-  en: {
-    'siteName': 'Yaqiin Boycott',
-    'siteSlogan': 'Ethical Boycott, Supporting Our Brothers',
-    'toggleLanguage': 'العربية',
-    'searchPlaceholder': 'Search for a product',
-    'productName': 'Product Name',
-    'countryOfOrigin': 'Country of Origin',
-    'alternatives': 'Alternatives',
-    'noAlternatives': 'No alternatives available yet',
-    'loading': 'Loading...',
-    'error': 'Error loading data',
-    'pageNotFound': 'Oops! Page not found',
-    'backHome': 'Back Home',
-    'introText': 'A platform to help you discover ethical alternatives to Israeli and US tech products.',
-    'copyright': 'Yaqiin Boycott. All rights reserved.'
-  }
-};
-
 interface LanguageProviderProps {
   children: ReactNode;
 }
 
 export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
-  // Default to Arabic
+  const [isLoading, setIsLoading] = useState(true);
   const [language, setLanguageState] = useState<Language>('ar');
 
-  // On mount, check if language is stored
   useEffect(() => {
     const storedLang = localStorage.getItem('language') as Language;
-    if (storedLang && (storedLang === 'ar' || storedLang === 'en')) {
+    if (storedLang && storedLang in SUPPORTED_LANGUAGES) {
       setLanguageState(storedLang);
+    } else {
+      // Detect browser language
+      const browserLang = navigator.language.split('-')[0] as Language;
+      if (browserLang in SUPPORTED_LANGUAGES) {
+        setLanguageState(browserLang);
+      }
     }
+    setIsLoading(false);
   }, []);
 
-  // Update HTML dir attribute when language changes
   useEffect(() => {
-    document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr';
+    document.documentElement.dir = SUPPORTED_LANGUAGES[language].dir;
     document.documentElement.lang = language;
     localStorage.setItem('language', language);
   }, [language]);
@@ -75,15 +37,15 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
     setLanguageState(lang);
   };
 
-  // Translation function
-  const t = (key: string): string => {
-    return translations[language][key as keyof typeof translations['en']] || key;
+  const t = (key: TranslationKey): string => {
+    const translation = translations[language][key];
+    return translation || translations['ar'][key] || key;
   };
 
-  const isRTL = language === 'ar';
+  const isRTL = SUPPORTED_LANGUAGES[language].dir === 'rtl';
 
   return (
-    <LanguageContext.Provider value={{ language, setLanguage, t, isRTL }}>
+    <LanguageContext.Provider value={{ language, setLanguage, t, isRTL, supportedLanguages: SUPPORTED_LANGUAGES, isLoading }}>
       {children}
     </LanguageContext.Provider>
   );
diff --git a/src/contexts/languageConfig.ts b/src/contexts/languageConfig.ts
new file mode 100644
index 0000000..64bedb5
--- /dev/null
+++ b/src/contexts/languageConfig.ts
@@ -0,0 +1,93 @@
+export interface LanguageConfig {
+  code: string;
+  name: string;
+  nativeName: string;
+  dir: 'rtl' | 'ltr';
+}
+
+export const SUPPORTED_LANGUAGES: Record<string, LanguageConfig> = {
+  ar: { code: 'ar', name: 'Arabic', nativeName: 'العربية', dir: 'rtl' },
+  en: { code: 'en', name: 'English', nativeName: 'English', dir: 'ltr' },
+  fr: { code: 'fr', name: 'French', nativeName: 'Français', dir: 'ltr' },
+  es: { code: 'es', name: 'Spanish', nativeName: 'Español', dir: 'ltr' }
+};
+
+export type Language = keyof typeof SUPPORTED_LANGUAGES;
+
+export const translations = {
+  ar: {
+    'siteName': 'يقين للمقاطعة',
+    'siteSlogan': 'مقاطعة أخلاقية، نصرة لأخوتنا',
+    'toggleLanguage': 'English',
+    'searchPlaceholder': 'ابحث عن منتج',
+    'productName': 'اسم المنتج',
+    'countryOfOrigin': 'بلد المنشأ',
+    'alternatives': 'البدائل',
+    'noAlternatives': 'لا توجد بدائل متاحة حاليًا',
+    'loading': 'جاري التحميل...',
+    'error': 'حدث خطأ في تحميل البيانات',
+    'pageNotFound': 'عذرًا! الصفحة غير موجودة',
+    'backHome': 'العودة إلى الصفحة الرئيسية',
+    'introText': 'منصة تساعدك على اكتشاف بدائل أخلاقية للمنتجات التقنية الأمريكية والإسرائيلية.',
+    'copyright': 'يقين للمقاطعة. جميع الحقوق محفوظة.'
+  },
+  en: {
+    'siteName': 'Yaqiin Boycott',
+    'siteSlogan': 'Ethical Boycott, Supporting Our Brothers',
+    'toggleLanguage': 'العربية',
+    'searchPlaceholder': 'Search for a product',
+    'productName': 'Product Name',
+    'countryOfOrigin': 'Country of Origin',
+    'alternatives': 'Alternatives',
+    'noAlternatives': 'No alternatives available yet',
+    'loading': 'Loading...',
+    'error': 'Error loading data',
+    'pageNotFound': 'Oops! Page not found',
+    'backHome': 'Back Home',
+    'introText': 'A platform to help you discover ethical alternatives to Israeli and US tech products.',
+    'copyright': 'Yaqiin Boycott. All rights reserved.'
+  },
+  fr: {
+    'siteName': 'Yaqiin Boycott',
+    'siteSlogan': 'Boycott Éthique, Soutenir Nos Frères',
+    'toggleLanguage': 'Changer de langue',
+    'searchPlaceholder': 'Rechercher un produit',
+    'productName': 'Nom du Produit',
+    'countryOfOrigin': 'Pays d\'Origine',
+    'alternatives': 'Alternatives',
+    'noAlternatives': 'Aucune alternative disponible pour le moment',
+    'loading': 'Chargement...',
+    'error': 'Erreur lors du chargement des données',
+    'pageNotFound': 'Oups ! Page non trouvée',
+    'backHome': 'Retour à l\'Accueil',
+    'introText': 'Une plateforme pour vous aider à découvrir des alternatives éthiques aux produits technologiques israéliens et américains.',
+    'copyright': 'Yaqiin Boycott. Tous droits réservés.'
+  },
+  es: {
+    'siteName': 'Yaqiin Boicot',
+    'siteSlogan': 'Boicot Ético, Apoyando a Nuestros Hermanos',
+    'toggleLanguage': 'Cambiar Idioma',
+    'searchPlaceholder': 'Buscar un producto',
+    'productName': 'Nombre del Producto',
+    'countryOfOrigin': 'País de Origen',
+    'alternatives': 'Alternativas',
+    'noAlternatives': 'No hay alternativas disponibles aún',
+    'loading': 'Cargando...',
+    'error': 'Error al cargar datos',
+    'pageNotFound': '¡Ups! Página no encontrada',
+    'backHome': 'Volver al Inicio',
+    'introText': 'Una plataforma para ayudarte a descubrir alternativas éticas a productos tecnológicos israelíes y estadounidenses.',
+    'copyright': 'Yaqiin Boicot. Todos los derechos reservados.'
+  }
+};
+
+export type TranslationKey = keyof typeof translations['ar'];
+
+export interface LanguageContextType {
+  language: Language;
+  setLanguage: (lang: Language) => void;
+  t: (key: TranslationKey) => string;
+  isRTL: boolean;
+  supportedLanguages: typeof SUPPORTED_LANGUAGES;
+  isLoading: boolean;
+}


Reviews:
user: login: HouariZegai
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T12:32:47Z
user: login: HouariZegai
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:20:45Z
user: login: HouariZegai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T16:10:10Z
user: login: HouariZegai
body: A very nice contribution. Thank you @oxcodexo!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T16:10:25Z

Review comments:
diff_hunk: @@ -75,15 +39,16 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
     setLanguageState(lang);
   };
 
-  // Translation function
-  const t = (key: string): string => {
-    return translations[language][key as keyof typeof translations['en']] || key;
+  // Translation function with English fallback
+  const t = (key: TranslationKey): string => {
+    const translation = translations[language][key];
+    return translation || translations['en'][key] || key;
path: src/contexts/LanguageContext.tsx
user: login: HouariZegai
body: We can fallback to Arabic (Arabic as default language)
created_at: 2025-04-09T12:30:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,96 @@
+// Language configuration
+export interface LanguageConfig {
+  code: string;
+  name: string;
+  nativeName: string;
+  dir: 'rtl' | 'ltr';
+}
+
+export const SUPPORTED_LANGUAGES: Record<string, LanguageConfig> = {
+  ar: { code: 'ar', name: 'Arabic', nativeName: 'العربية', dir: 'rtl' },
+  en: { code: 'en', name: 'English', nativeName: 'English', dir: 'ltr' },
+  fr: { code: 'fr', name: 'French', nativeName: 'Français', dir: 'ltr' },
+  es: { code: 'es', name: 'Spanish', nativeName: 'Español', dir: 'ltr' }
+};
+
+export type Language = keyof typeof SUPPORTED_LANGUAGES;
+
+// Translations object
path: src/contexts/languageConfig.ts
user: login: HouariZegai
body: Unnecessary code comment because the code explains itself (consider same remark in other places).

See: [Necessary Comments - Clean Coder](https://blog.cleancoder.com/uncle-bob/2017/02/23/NecessaryComments.html)
created_at: 2025-04-09T12:32:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,10 +37,9 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
     setLanguageState(lang);
   };
 
-  // Translation function with English fallback
   const t = (key: TranslationKey): string => {
     const translation = translations[language][key];
-    return translation || translations['en'][key] || key;
+    return translation || translations['ar'][key] || key;
path: src/contexts/LanguageContext.tsx
user: login: HouariZegai
body: We can avoid the magic literal by creating a constant of the default language, something like:
```js
return translation || translations[DEFAULT_LANGUAGE][key] || key;
```

See:
- [Replace Magic Literal - Refactoring](https://www.refactoring.com/catalog/replaceMagicLiteral.html)
- [Magic Numbers](https://clang.llvm.org/extra/clang-tidy/checks/readability/magic-numbers.html)
created_at: 2025-04-09T15:20:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,10 +37,9 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
     setLanguageState(lang);
   };
 
-  // Translation function with English fallback
   const t = (key: TranslationKey): string => {
     const translation = translations[language][key];
-    return translation || translations['en'][key] || key;
+    return translation || translations['ar'][key] || key;
path: src/contexts/LanguageContext.tsx
user: login: HouariZegai
body: Not a blocking comment, will do the refactoring after the merge.
created_at: 2025-04-09T16:10:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: oxcodexo
created_at: 2025-04-09T14:24:42Z
body: I’ve made the requested changes:
-Updated the translation fallback to use Arabic as the default language.
-Removed unnecessary comments.

Please let me know if there’s anything else I should adjust 🙂.
author_association: CONTRIBUTOR
user: login: oxcodexo
created_at: 2025-04-09T17:46:07Z
body: Thank you! I'm glad I could help. Looking forward to contributing more in the future 😊.
author_association: CONTRIBUTOR