diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml
index ee1dd8c7c..d87642e1e 100644
--- a/.github/workflows/translations-download.yaml
+++ b/.github/workflows/translations-download.yaml
@@ -19,7 +19,7 @@ jobs:
uses: localazy/download@v1.1.0
- name: "Fix the owner of the downloaded files"
- run: "sudo chown runner:docker translations/*.json"
+ run: "sudo chown runner:docker translations/*.json frontend/locales/*.json"
- name: Create Pull Request
id: cpr
@@ -29,6 +29,7 @@ jobs:
branch: actions/localazy-download
delete-branch: true
title: Localazy Download
+ commit-message: Translations updates
- name: Enable automerge
run: gh pr merge --merge --auto "$PR_NUMBER"
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
index 7ea4e461f..9665251ce 100644
--- a/frontend/.storybook/main.ts
+++ b/frontend/.storybook/main.ts
@@ -38,6 +38,9 @@ const config: StorybookConfig = {
// Theme switch toolbar
"@storybook/addon-toolbars",
+
+ // i18next integration
+ "storybook-react-i18next",
],
framework: "@storybook/react-vite",
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index 9ae2ab324..028d64f16 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -12,9 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import { ArgTypes, Decorator, Parameters } from "@storybook/react";
+import { ArgTypes, Decorator, Parameters, Preview } from "@storybook/react";
import { useLayoutEffect } from "react";
+
import "../src/main.css";
+import i18n from "../src/i18n";
export const parameters: Parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
@@ -74,3 +76,18 @@ const withThemeProvider: Decorator = (Story, context) => {
};
export const decorators: Decorator[] = [withThemeProvider];
+
+const preview: Preview = {
+ globals: {
+ locale: "en",
+ locales: {
+ en: "English",
+ fr: "Français",
+ },
+ },
+ parameters: {
+ i18n,
+ },
+};
+
+export default preview;
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 80c1e0d1b..812933832 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -1,5 +1,6 @@
{
"action": {
+ "back": "Back",
"cancel": "Cancel",
"continue": "Continue",
"save": "Save"
@@ -131,9 +132,11 @@
},
"verify_email": {
"code_field_label": "6-digit code",
- "enter_code_prompt": "Enter the 6-digit code sent to <1>{{email}1>",
+ "enter_code_prompt": "Enter the 6-digit code sent to <2>{{email}}2>",
"heading": "Verify your email",
"invalid_code_alert": "Invalid code",
+ "resend_email": "Resend email",
+ "sent": "Sent!",
"unknown_email": "Unknown email"
}
}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5aa9c29c3..f83cff747 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -64,6 +64,7 @@
"react-test-renderer": "^18.2.0",
"rimraf": "^5.0.5",
"storybook": "^7.5.0",
+ "storybook-react-i18next": "^2.0.9",
"tailwindcss": "^3.3.3",
"typescript": "5.2.2",
"vite": "^4.4.11",
@@ -21674,6 +21675,57 @@
"url": "https://opencollective.com/storybook"
}
},
+ "node_modules/storybook-i18n": {
+ "version": "2.0.13",
+ "resolved": "https://registry.npmjs.org/storybook-i18n/-/storybook-i18n-2.0.13.tgz",
+ "integrity": "sha512-p0VPL5QiHdeS3W9BvV7UnuTKw7Mj1HWLW67LK0EOoh5fpSQIchu7byfrUUe1RbCF1gT0gOOhdNuTSXMoVVoTDw==",
+ "dev": true,
+ "peerDependencies": {
+ "@storybook/components": "^7.0.0",
+ "@storybook/manager-api": "^7.0.0",
+ "@storybook/preview-api": "^7.0.0",
+ "@storybook/types": "^7.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/storybook-react-i18next": {
+ "version": "2.0.9",
+ "resolved": "https://registry.npmjs.org/storybook-react-i18next/-/storybook-react-i18next-2.0.9.tgz",
+ "integrity": "sha512-GFTOrYwOWShLqWNuTesPNhC79P3OHw1jkZ4gU3R50yTD2MUclF5DHLnuKeVfKZ323iV+I9fxLxuLIVHWVDJgXA==",
+ "dev": true,
+ "dependencies": {
+ "storybook-i18n": "2.0.13"
+ },
+ "peerDependencies": {
+ "@storybook/components": "^7.0.0",
+ "@storybook/manager-api": "^7.0.0",
+ "@storybook/preview-api": "^7.0.0",
+ "@storybook/types": "^7.0.0",
+ "i18next": "^22.0.0 || ^23.0.0",
+ "i18next-browser-languagedetector": "^7.0.0",
+ "i18next-http-backend": "^2.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-i18next": "^12.0.0 || ^13.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/stream-composer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 441aebad3..889d64c1d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -72,6 +72,7 @@
"react-test-renderer": "^18.2.0",
"rimraf": "^5.0.5",
"storybook": "^7.5.0",
+ "storybook-react-i18next": "^2.0.9",
"tailwindcss": "^3.3.3",
"typescript": "5.2.2",
"vite": "^4.4.11",
diff --git a/frontend/src/components/VerifyEmail/VerifyEmail.tsx b/frontend/src/components/VerifyEmail/VerifyEmail.tsx
index 7f9285847..a0314450f 100644
--- a/frontend/src/components/VerifyEmail/VerifyEmail.tsx
+++ b/frontend/src/components/VerifyEmail/VerifyEmail.tsx
@@ -113,6 +113,7 @@ const BackButton: React.FC = () => {
const { onClick, href, pending } = useNavigationLink({
type: "profile",
});
+ const { t } = useTranslation();
return (
);
};
@@ -187,7 +188,7 @@ const VerifyEmail: React.FC<{
Enter the 6-digit code sent to{" "}
- {{ codeEmail }}
+ {{ email: codeEmail }}
@@ -222,7 +223,9 @@ const VerifyEmail: React.FC<{
disabled={pending || emailSent}
onClick={onResendClick}
>
- {emailSent ? "Sent!" : "Resend email"}
+ {emailSent
+ ? t("frontend.verify_email.sent")
+ : t("frontend.verify_email.resend_email")}
diff --git a/frontend/src/gql/fragment-masking.ts b/frontend/src/gql/fragment-masking.ts
index 2ba06f10b..373a5ce41 100644
--- a/frontend/src/gql/fragment-masking.ts
+++ b/frontend/src/gql/fragment-masking.ts
@@ -1,15 +1,17 @@
-import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
-import { FragmentDefinitionNode } from 'graphql';
-import { Incremental } from './graphql';
+import {
+ ResultOf,
+ DocumentTypeDecoration,
+ TypedDocumentNode,
+} from "@graphql-typed-document-node/core";
+import { FragmentDefinitionNode } from "graphql";
+import { Incremental } from "./graphql";
-
-export type FragmentType> = TDocumentType extends DocumentTypeDecoration<
- infer TType,
- any
->
- ? [TType] extends [{ ' $fragmentName'?: infer TKey }]
+export type FragmentType<
+ TDocumentType extends DocumentTypeDecoration,
+> = TDocumentType extends DocumentTypeDecoration
+ ? [TType] extends [{ " $fragmentName"?: infer TKey }]
? TKey extends string
- ? { ' $fragmentRefs'?: { [key in TKey]: TType } }
+ ? { " $fragmentRefs"?: { [key in TKey]: TType } }
: never
: never
: never;
@@ -17,50 +19,67 @@ export type FragmentType>
// return non-nullable if `fragmentType` is non-nullable
export function useFragment(
_documentNode: DocumentTypeDecoration,
- fragmentType: FragmentType>
+ fragmentType: FragmentType>,
): TType;
// return nullable if `fragmentType` is nullable
export function useFragment(
_documentNode: DocumentTypeDecoration,
- fragmentType: FragmentType> | null | undefined
+ fragmentType:
+ | FragmentType>
+ | null
+ | undefined,
): TType | null | undefined;
// return array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment(
_documentNode: DocumentTypeDecoration,
- fragmentType: ReadonlyArray>>
+ fragmentType: ReadonlyArray>>,
): ReadonlyArray;
// return array of nullable if `fragmentType` is array of nullable
export function useFragment(
_documentNode: DocumentTypeDecoration,
- fragmentType: ReadonlyArray>> | null | undefined
+ fragmentType:
+ | ReadonlyArray>>
+ | null
+ | undefined,
): ReadonlyArray | null | undefined;
export function useFragment(
_documentNode: DocumentTypeDecoration,
- fragmentType: FragmentType> | ReadonlyArray>> | null | undefined
+ fragmentType:
+ | FragmentType>
+ | ReadonlyArray>>
+ | null
+ | undefined,
): TType | ReadonlyArray | null | undefined {
return fragmentType as any;
}
-
export function makeFragmentData<
F extends DocumentTypeDecoration,
- FT extends ResultOf
+ FT extends ResultOf,
>(data: FT, _fragment: F): FragmentType {
return data as FragmentType;
}
export function isFragmentReady(
queryNode: DocumentTypeDecoration,
fragmentNode: TypedDocumentNode,
- data: FragmentType, any>> | null | undefined
+ data:
+ | FragmentType, any>>
+ | null
+ | undefined,
): data is FragmentType {
- const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__
- ?.deferredFields;
+ const deferredFields = (
+ queryNode as {
+ __meta__?: { deferredFields: Record };
+ }
+ ).__meta__?.deferredFields;
if (!deferredFields) return true;
- const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
+ const fragDef = fragmentNode.definitions[0] as
+ | FragmentDefinitionNode
+ | undefined;
const fragName = fragDef?.name?.value;
const fields = (fragName && deferredFields[fragName]) || [];
- return fields.length > 0 && fields.every(field => data && field in data);
+ return fields.length > 0 && fields.every((field) => data && field in data);
}
diff --git a/frontend/src/gql/index.ts b/frontend/src/gql/index.ts
index f51599168..0ea4a91cf 100644
--- a/frontend/src/gql/index.ts
+++ b/frontend/src/gql/index.ts
@@ -1,2 +1,2 @@
export * from "./fragment-masking";
-export * from "./gql";
\ No newline at end of file
+export * from "./gql";
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 99efb00d3..d5cbfccd5 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -12,9 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import * as i18n from "i18next";
-import { InitOptions } from "i18next";
-import LanguageDetector from "i18next-browser-languagedetector";
+import { default as i18n, InitOptions } from "i18next";
+import LanguageDetector, {
+ DetectorOptions,
+} from "i18next-browser-languagedetector";
import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
@@ -44,6 +45,9 @@ i18n
keySeparator: ".",
pluralSeparator: ":",
supportedLngs,
+ detection: {
+ order: ["navigator", "htmlTag"],
+ } satisfies DetectorOptions,
interpolation: {
escapeValue: false, // React has built-in XSS protections
},
@@ -60,7 +64,7 @@ i18n
import.meta.hot?.on("locales-update", () => {
i18n.reloadResources().then(() => {
- i18n.changeLanguage(i18n.default.language);
+ i18n.changeLanguage(i18n.language);
});
});
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 786901425..57959552e 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -16,13 +16,14 @@ import { Provider } from "jotai";
import { DevTools } from "jotai-devtools";
import { Suspense, StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { I18nextProvider } from "react-i18next";
import { HydrateAtoms } from "./atoms";
import Layout from "./components/Layout";
import LoadingScreen from "./components/LoadingScreen";
import LoadingSpinner from "./components/LoadingSpinner";
+import i18n from "./i18n";
import { Router } from "./routing";
-import "./i18n";
import "./main.css";
createRoot(document.getElementById("root") as HTMLElement).render(
@@ -31,11 +32,13 @@ createRoot(document.getElementById("root") as HTMLElement).render(
{import.meta.env.DEV && }
}>
-
- }>
-
-
-
+
+
+ }>
+
+
+
+
diff --git a/frontend/src/routing/Link.tsx b/frontend/src/routing/Link.tsx
index 206cc60f5..3cd4dbc52 100644
--- a/frontend/src/routing/Link.tsx
+++ b/frontend/src/routing/Link.tsx
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+import { useTranslation } from "react-i18next";
+
import styles from "./Link.module.css";
import { Route } from "./routes";
import { useNavigationLink } from "./useNavigationLink";
@@ -24,6 +26,7 @@ const Link: React.FC<
} & React.HTMLProps
> = ({ route, children, kind, className, ...props }) => {
const { onClick, href, pending } = useNavigationLink(route);
+ const { t } = useTranslation();
const classNames = [
kind === "button" ? styles.linkButton : "",
@@ -32,7 +35,7 @@ const Link: React.FC<
return (
- {pending ? "Loading..." : children}
+ {pending ? t("common.loading") : children}
);
};
diff --git a/templates/app.html b/templates/app.html
index 2d8a54ba4..694e9a865 100644
--- a/templates/app.html
+++ b/templates/app.html
@@ -18,7 +18,7 @@ limitations under the License.
{% set _ = translator(lang) %}
-
+