♻️ Update locale implementation

This commit is contained in:
Luke Vella 2025-04-23 10:49:02 +01:00
parent 42d0077045
commit 99ca9a180d
No known key found for this signature in database
GPG key ID: 469CAD687F0D784C
10 changed files with 132 additions and 85 deletions

View file

@ -57,6 +57,7 @@
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"i18next": "^24.2.2", "i18next": "^24.2.2",
"i18next-http-backend": "^3.0.2",
"i18next-icu": "^2.3.0", "i18next-icu": "^2.3.0",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"ics": "^3.1.0", "ics": "^3.1.0",

View file

@ -3,7 +3,6 @@ import { Button } from "@rallly/ui/button";
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form"; import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@ -20,7 +19,6 @@ type FormData = z.infer<typeof formSchema>;
export const LanguagePreference = () => { export const LanguagePreference = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const router = useRouter();
const form = useForm<FormData>({ const form = useForm<FormData>({
defaultValues: { defaultValues: {
language: i18n.language, language: i18n.language,
@ -34,7 +32,8 @@ export const LanguagePreference = () => {
<form <form
onSubmit={form.handleSubmit(async (data) => { onSubmit={form.handleSubmit(async (data) => {
await updatePreferences({ locale: data.language }); await updatePreferences({ locale: data.language });
router.refresh(); i18n.changeLanguage(data.language);
form.reset({ language: data.language });
})} })}
> >
<FormField <FormField

View file

@ -4,7 +4,6 @@ import "../../style.css";
import { PostHogProvider } from "@rallly/posthog/client"; import { PostHogProvider } from "@rallly/posthog/client";
import { Toaster } from "@rallly/ui/toaster"; import { Toaster } from "@rallly/ui/toaster";
import { TooltipProvider } from "@rallly/ui/tooltip"; import { TooltipProvider } from "@rallly/ui/tooltip";
import { dehydrate, Hydrate } from "@tanstack/react-query";
import { domAnimation, LazyMotion } from "motion/react"; import { domAnimation, LazyMotion } from "motion/react";
import type { Viewport } from "next"; import type { Viewport } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
@ -13,14 +12,16 @@ import React from "react";
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector"; import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
import { UserProvider } from "@/components/user-provider"; import { UserProvider } from "@/components/user-provider";
import { getUser } from "@/data/get-user";
import { TimezoneProvider } from "@/features/timezone/client/context"; import { TimezoneProvider } from "@/features/timezone/client/context";
import { I18nProvider } from "@/i18n/client"; import { I18nProvider } from "@/i18n/client";
import { auth } from "@/next-auth"; import { getLocale } from "@/i18n/server/get-locale";
import { auth, getUserId } from "@/next-auth";
import { TRPCProvider } from "@/trpc/client/provider"; import { TRPCProvider } from "@/trpc/client/provider";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
import { ConnectedDayjsProvider } from "@/utils/dayjs"; import { ConnectedDayjsProvider } from "@/utils/dayjs";
import { PostHogPageView } from "../posthog-page-view"; import { PostHogPageView } from "../posthog-page-view";
import { defaultLocale, supportedLngs } from "@rallly/languages";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@ -32,44 +33,58 @@ export const viewport: Viewport = {
initialScale: 1, initialScale: 1,
}; };
async function loadLocale() {
let locale = getLocale();
const userId = await getUserId();
if (userId) {
const user = await getUser();
if (user.locale) {
locale = user.locale;
}
}
if (!supportedLngs.includes(locale)) {
return defaultLocale;
}
return locale;
}
export default async function Root({ export default async function Root({
children, children,
params: { locale },
}: { }: {
children: React.ReactNode; children: React.ReactNode;
params: { locale: string };
}) { }) {
const session = await auth(); const session = await auth();
const trpc = await createSSRHelper(); const locale = await loadLocale();
await trpc.user.subscription.prefetch();
return ( return (
<html lang={locale} className={inter.className}> <html lang={locale} className={inter.className}>
<body> <body>
<Toaster /> <Toaster />
<I18nProvider> <I18nProvider locale={locale}>
<TRPCProvider> <TRPCProvider>
<Hydrate state={dehydrate(trpc.queryClient)}> <LazyMotion features={domAnimation}>
<LazyMotion features={domAnimation}> <SessionProvider session={session}>
<SessionProvider session={session}> <PostHogProvider>
<PostHogProvider> <PostHogPageView />
<PostHogPageView /> <TooltipProvider>
<TooltipProvider> <UserProvider>
<UserProvider> <TimezoneProvider
<TimezoneProvider initialTimezone={session?.user?.timeZone ?? undefined}
initialTimezone={session?.user?.timeZone ?? undefined} >
> <ConnectedDayjsProvider>
<ConnectedDayjsProvider> {children}
{children} <TimeZoneChangeDetector />
<TimeZoneChangeDetector /> </ConnectedDayjsProvider>
</ConnectedDayjsProvider> </TimezoneProvider>
</TimezoneProvider> </UserProvider>
</UserProvider> </TooltipProvider>
</TooltipProvider> </PostHogProvider>
</PostHogProvider> </SessionProvider>
</SessionProvider> </LazyMotion>
</LazyMotion>
</Hydrate>
</TRPCProvider> </TRPCProvider>
</I18nProvider> </I18nProvider>
</body> </body>

View file

@ -1,5 +1,5 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import httpBackend from "i18next-http-backend";
import React from "react"; import React from "react";
import { import {
I18nextProvider, I18nextProvider,
@ -14,12 +14,20 @@ export function useTranslation() {
return useTranslationOrg("app"); return useTranslationOrg("app");
} }
export function I18nProvider({ children }: { children: React.ReactNode }) { export function I18nProvider({
const params = useParams<{ locale: string }>(); children,
const locale = params?.locale ?? defaultNS; locale,
}: {
children: React.ReactNode;
locale: string;
}) {
const res = useAsync(async () => { const res = useAsync(async () => {
return await initI18next(locale, "app"); return await initI18next({
lng: locale,
middleware: (i18n) => {
i18n.use(httpBackend);
},
});
}); });
if (!res.value) { if (!res.value) {
@ -27,7 +35,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
} }
return ( return (
<I18nextProvider i18n={res.value} defaultNS={defaultNS}> <I18nextProvider i18n={res.value.i18n} defaultNS={defaultNS}>
{children} {children}
</I18nextProvider> </I18nextProvider>
); );

View file

@ -1,22 +1,21 @@
import type { Namespace } from "i18next"; import type { i18n, Namespace } from "i18next";
import { createInstance } from "i18next"; import { createInstance } from "i18next";
import ICU from "i18next-icu"; import ICU from "i18next-icu";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next"; import { initReactI18next } from "react-i18next/initReactI18next";
import { getOptions } from "./settings"; import { getOptions } from "./settings";
export const initI18next = async (lng: string, ns: Namespace) => { export const initI18next = async ({
const i18nInstance = createInstance(); lng,
await i18nInstance ns,
.use(initReactI18next) middleware,
.use(ICU) }: {
.use( lng: string;
resourcesToBackend( ns?: Namespace;
(language: string, namespace: string) => middleware: (i18n: i18n) => void;
import(`../../public/locales/${language}/${namespace}.json`), }) => {
), const i18nInstance = createInstance().use(initReactI18next).use(ICU);
) middleware(i18nInstance);
.init(getOptions(lng, ns)); const t = await i18nInstance.init(getOptions(lng, ns));
return i18nInstance; return { t, i18n: i18nInstance };
}; };

View file

@ -1,14 +1,25 @@
import resourcesToBackend from "i18next-resources-to-backend";
import { defaultNS } from "@/i18n/settings"; import { defaultNS } from "@/i18n/settings";
import { getLocaleFromPath } from "@/utils/locale/get-locale-from-path";
import { initI18next } from "./i18n"; import { initI18next } from "./i18n";
import { getLocale } from "./server/get-locale";
export async function getTranslation(localeOverride?: string) { export async function getTranslation(localeOverride?: string) {
const localeFromPath = getLocaleFromPath(); const locale = localeOverride || getLocale();
const locale = localeOverride || localeFromPath; const { i18n } = await initI18next({
const i18nextInstance = await initI18next(locale, defaultNS); lng: locale,
middleware: (i18n) => {
i18n.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`../../public/locales/${language}/${namespace}.json`),
),
);
},
});
return { return {
t: i18nextInstance.getFixedT(locale, defaultNS), t: i18n.getFixedT(locale, defaultNS),
i18n: i18nextInstance, i18n,
}; };
} }

View file

@ -0,0 +1,11 @@
import { defaultLocale } from "@rallly/languages";
import { headers } from "next/headers";
export function getLocale() {
const headersList = headers();
const localeFromHeader = headersList.get("x-locale");
if (!localeFromHeader) {
return defaultLocale;
}
return localeFromHeader;
}

View file

@ -10,30 +10,23 @@ const supportedLocales = Object.keys(languages);
export const middleware = withAuth(async (req) => { export const middleware = withAuth(async (req) => {
const { nextUrl } = req; const { nextUrl } = req;
const newUrl = nextUrl.clone(); const newUrl = nextUrl.clone();
const pathname = newUrl.pathname;
const isLoggedIn = req.auth?.user?.email; const isLoggedIn = req.auth?.user?.email;
// if the user is already logged in, don't let them access the login page // if the user is already logged in, don't let them access the login page
if ( if (/^\/(login)/.test(pathname) && isLoggedIn) {
/^\/(login)/.test(newUrl.pathname) &&
isLoggedIn &&
!newUrl.searchParams.get("invalidSession")
) {
newUrl.pathname = "/"; newUrl.pathname = "/";
return NextResponse.redirect(newUrl); return NextResponse.redirect(newUrl);
} }
// Check if locale is specified in cookie const locale = req.auth?.user?.locale || getPreferredLocale(req);
let locale = req.auth?.user?.locale; if (supportedLocales.includes(locale)) {
if (locale && supportedLocales.includes(locale)) { newUrl.pathname = `/${locale}${pathname}`;
newUrl.pathname = `/${locale}${newUrl.pathname}`;
} else {
// Check if locale is specified in header
locale = getPreferredLocale(req);
newUrl.pathname = `/${locale}${newUrl.pathname}`;
} }
const res = NextResponse.rewrite(newUrl); const res = NextResponse.rewrite(newUrl);
res.headers.set("x-pathname", newUrl.pathname); res.headers.set("x-locale", locale);
res.headers.set("x-pathname", pathname);
if (req.auth?.user?.id) { if (req.auth?.user?.id) {
await withPostHog(res, { distinctID: req.auth.user.id }); await withPostHog(res, { distinctID: req.auth.user.id });

View file

@ -1,11 +0,0 @@
import { defaultLocale, supportedLngs } from "@rallly/languages";
import { headers } from "next/headers";
export function getLocaleFromPath() {
const headersList = headers();
const pathname = headersList.get("x-pathname") || defaultLocale;
const localeFromPath = pathname.split("/")[1];
return supportedLngs.includes(localeFromPath)
? localeFromPath
: defaultLocale;
}

View file

@ -8102,6 +8102,13 @@ cross-env@^7.0.3:
dependencies: dependencies:
cross-spawn "^7.0.1" cross-spawn "^7.0.1"
cross-fetch@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983"
integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==
dependencies:
node-fetch "^2.6.12"
cross-spawn@^6.0.5: cross-spawn@^6.0.5:
version "6.0.5" version "6.0.5"
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz"
@ -9925,6 +9932,13 @@ hyphenate-style-name@^1.0.3:
resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz" resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz"
integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ== integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
i18next-http-backend@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz#7c8daa31aa69679e155ec1f96a37846225bdf907"
integrity sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==
dependencies:
cross-fetch "4.0.0"
i18next-icu@^2.3.0: i18next-icu@^2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.npmjs.org/i18next-icu/-/i18next-icu-2.3.0.tgz" resolved "https://registry.npmjs.org/i18next-icu/-/i18next-icu-2.3.0.tgz"
@ -11583,6 +11597,13 @@ nice-try@^1.0.4:
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz" resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
node-fetch@^2.6.12:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
dependencies:
whatwg-url "^5.0.0"
node-fetch@^2.6.7: node-fetch@^2.6.7:
version "2.6.9" version "2.6.9"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz"