Bläddra i källkod

feat/detect locale (#64)

* refactor(exercise-list-item.tsx, exercises-selection.tsx): simplify ExerciseListItem component by removing unused state and props, and replace MouseSensor and TouchSensor with PointerSensor for better drag-and-drop performance.

* fix(exercise-list-item.tsx): increase zIndex and set position to relative when dragging to improve drag-and-drop functionality

* feat(exercise-list-item): add video thumbnail and modal for exercise videos to enhance user experience and engagement

* feat(exercise-list-item): enhance exercise display with muscle color coding and improved UI elements
fix(exercises-selection): rename onPick prop to avoid confusion and clarify intent

* feat(exercise-list-item): refactor exercise list item to use Button component and improve accessibility with tooltips
fix(exercises-selection): adjust drag sensor activation constraints for better user experience

* style(exercise-list-item.tsx): enhance user experience by preventing text selection during drag and drop operations

* refactor(ui): update variable names and improve UI component structure for better readability and maintainability

* feat(exercises-selection.tsx): add MouseSensor to improve drag-and-drop experience for exercise selection

* refactor(exercise-list-item.tsx): replace useState with custom useBoolean hook for managing video playback state to simplify state management and improve readability

* feat(locale): implement automatic locale detection and user locale update functionality to enhance user experience
feat(middleware): add locale detection logic in middleware to redirect users based on their browser settings
feat(language-selector): update language selection to manage locale cookies and trigger immediate locale changes for better UX
feat(auto-locale): create hooks for automatic locale detection and user locale updates to streamline localization process

* style(update-user-locale.ts): refine comments for clarity and consistency in code documentation

* refactor(language-selector): extract language name logic into a separate function for better readability and maintainability
Mat B. 1 månad sedan
förälder
incheckning
0c30fa4d38

+ 7 - 0
app/[locale]/providers.tsx

@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { I18nProviderClient } from "locales/client";
 import { AnalyticsProvider } from "@/shared/lib/analytics/client";
 import { DialogRenderer } from "@/features/dialogs-provider/DialogProvider";
+import { useAutoLocale } from "@/entities/user/model/use-auto-locale";
 import { ToastSonner } from "@/components/ui/ToastSonner";
 import { Toaster } from "@/components/ui/toaster";
 import { ThemeProvider } from "@/components/ui/theme-provider";
@@ -15,6 +16,11 @@ import type { PropsWithChildren } from "react";
 
 const queryClient = new QueryClient();
 
+function LocaleDetector() {
+  useAutoLocale();
+  return null;
+}
+
 export const Providers = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
   return (
     <>
@@ -23,6 +29,7 @@ export const Providers = ({ children, locale }: PropsWithChildren<{ locale: stri
         <QueryClientProvider client={queryClient}>
           <I18nProviderClient locale={locale}>
             <ThemeProvider attribute="class" defaultTheme="system" disableTransitionOnChange enableSystem>
+              <LocaleDetector />
               <Toaster />
               <ToastSonner />
               <DialogRenderer />

+ 68 - 1
middleware.ts

@@ -3,6 +3,44 @@ import { createI18nMiddleware } from "next-international/middleware";
 import { NextRequest, NextResponse } from "next/server";
 import { getSessionCookie } from "better-auth/cookies";
 
+function detectUserLocale(request: NextRequest): string {
+  const acceptLanguage = request.headers.get("accept-language");
+
+  if (!acceptLanguage) return "en";
+
+  // Parse Accept-Language header
+  const languages = acceptLanguage
+    .split(",")
+    .map((lang) => {
+      const [locale, quality = "1"] = lang.trim().split(";q=");
+      return { locale: locale.toLowerCase(), quality: parseFloat(quality) };
+    })
+    .sort((a, b) => b.quality - a.quality);
+
+  // Map browser locales to supported locales
+  const supportedLocales = ["en", "fr", "es", "zh-cn"];
+
+  for (const { locale } of languages) {
+    // Exact match
+    if (supportedLocales.includes(locale)) {
+      return locale === "zh-cn" ? "zh-CN" : locale;
+    }
+
+    // Language match (fr-FR -> fr)
+    const lang = locale.split("-")[0];
+    if (supportedLocales.includes(lang)) {
+      return lang;
+    }
+
+    // Chinese variants
+    if (locale.startsWith("zh")) {
+      return "zh-CN";
+    }
+  }
+
+  return "en"; // fallback
+}
+
 const I18nMiddleware = createI18nMiddleware({
   locales: ["en", "fr", "es", "zh-CN"],
   defaultLocale: "en",
@@ -10,9 +48,38 @@ const I18nMiddleware = createI18nMiddleware({
 });
 
 export async function middleware(request: NextRequest) {
+  const pathname = request.nextUrl.pathname;
+  const detectedLocale = detectUserLocale(request);
+
+  // If on root path and no locale detected yet, redirect to detected locale
+  if (pathname === "/" && !request.cookies.get("detected-locale")) {
+    const url = new URL(`/${detectedLocale}`, request.url);
+    const response = NextResponse.redirect(url);
+
+    response.cookies.set("detected-locale", detectedLocale, {
+      maxAge: 60 * 60 * 24 * 365, // 1 year
+      httpOnly: false,
+      secure: process.env.NODE_ENV === "production",
+      sameSite: "lax",
+    });
+
+    return response;
+  }
+
+  // Normal i18n middleware processing
   const response = I18nMiddleware(request);
-  const searchParams = request.nextUrl.searchParams.toString();
 
+  // Store detected locale in cookie for future visits
+  if (!request.cookies.get("detected-locale")) {
+    response.cookies.set("detected-locale", detectedLocale, {
+      maxAge: 60 * 60 * 24 * 365, // 1 year
+      httpOnly: false,
+      secure: process.env.NODE_ENV === "production",
+      sameSite: "lax",
+    });
+  }
+
+  const searchParams = request.nextUrl.searchParams.toString();
   response.headers.set("searchParams", searchParams);
 
   if (request.nextUrl.pathname.includes("/dashboard")) {

+ 34 - 0
src/entities/user/model/update-user-locale.ts

@@ -0,0 +1,34 @@
+"use client";
+
+import { useMutation } from "@tanstack/react-query";
+
+import { useCurrentUser } from "@/entities/user/model/useCurrentUser";
+import { updateUserAction } from "@/entities/user/model/update-user.action";
+
+interface UpdateUserLocaleParams {
+  locale: string;
+}
+
+export function useUpdateUserLocale() {
+  const user = useCurrentUser();
+
+  return useMutation({
+    mutationFn: async ({ locale }: UpdateUserLocaleParams) => {
+      if (!user) {
+        return;
+      }
+
+      const result = await updateUserAction({ locale });
+
+      if (!result?.data?.success) {
+        throw new Error("Failed to update user locale");
+      }
+
+      return result.data;
+    },
+    onError: (error) => {
+      console.error("Failed to update user locale:", error);
+      // silent fail, ux friendly
+    },
+  });
+}

+ 34 - 0
src/entities/user/model/use-auto-locale.ts

@@ -0,0 +1,34 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+import { useChangeLocale, useCurrentLocale } from "locales/client";
+import { useUpdateUserLocale } from "@/entities/user/model/update-user-locale";
+
+export function useAutoLocale() {
+  const currentLocale = useCurrentLocale();
+  const changeLocale = useChangeLocale();
+  const updateUserLocale = useUpdateUserLocale();
+  const hasAutoDetected = useRef(false);
+
+  useEffect(() => {
+    // Only run auto-detection once on mount
+    if (hasAutoDetected.current) return;
+
+    const detectedLocale = document.cookie
+      .split("; ")
+      .find((row) => row.startsWith("detected-locale="))
+      ?.split("=")[1];
+
+    // Only change if we have a detected locale different from current
+    if (detectedLocale && detectedLocale !== currentLocale) {
+      hasAutoDetected.current = true;
+
+      // Change locale on client
+      changeLocale(detectedLocale as any);
+
+      // Save to database silently
+      updateUserLocale.mutate({ locale: detectedLocale });
+    }
+  }, []); // Remove dependencies to run only once
+}

+ 23 - 7
src/widgets/language-selector/language-selector.tsx

@@ -20,8 +20,29 @@ export function LanguageSelector() {
   const t = useI18n();
 
   const handleLanguageChange = async (newLocale: string) => {
-    await action.execute({ locale: newLocale });
+    // update cookie to prevent auto-detection conflicts
+    document.cookie = `detected-locale=${newLocale}; max-age=${60 * 60 * 24 * 365}; path=/; samesite=lax`;
+
+    // change locale immediately for better UX
     changeLocale(newLocale as "en" | "fr" | "es" | "zh-CN");
+
+    // save to database (fire and forget)
+    action.execute({ locale: newLocale });
+  };
+
+  const getLanguageName = (language: string) => {
+    switch (language) {
+      case "en":
+        return "English";
+      case "fr":
+        return "Français";
+      case "es":
+        return "Español";
+      case "zh-CN":
+        return "中文";
+      default:
+        return language;
+    }
   };
 
   return (
@@ -46,12 +67,7 @@ export function LanguageSelector() {
               onClick={() => handleLanguageChange(language)}
             >
               <span className="text-lg">{languageFlags[language]}</span>
-              <span className="text-base whitespace-nowrap">
-                {language === "en" ? "English" : 
-                 language === "fr" ? "Français" : 
-                 language === "es" ? "Español" :
-                 language === "zh-CN" ? "中文" : language}
-              </span>
+              <span className="text-base whitespace-nowrap">{getLanguageName(language)}</span>
             </button>
           </li>
         ))}