diff --git a/app/favicon.ico b/app/[lang]/favicon.ico similarity index 100% rename from app/favicon.ico rename to app/[lang]/favicon.ico diff --git a/app/games/[gameId]/page.tsx b/app/[lang]/games/[gameId]/page.tsx similarity index 63% rename from app/games/[gameId]/page.tsx rename to app/[lang]/games/[gameId]/page.tsx index 9b790f6..6e6a4d8 100644 --- a/app/games/[gameId]/page.tsx +++ b/app/[lang]/games/[gameId]/page.tsx @@ -1,12 +1,13 @@ import { Game } from '@/src/components/game' import { getGame } from '@/src/lib/game.service.server' import { GameProvider } from '@/src/providers/game.provider' +import { LangProvider } from '@/src/providers/lang.provider' export default async function Home({ params, searchParams }: { - params: { gameId: string } + params: { gameId: string; lang: string } searchParams: { playerId: string } }) { const game = await getGame(params.gameId, searchParams.playerId) @@ -17,8 +18,10 @@ export default async function Home({ } return ( - - - + + + + + ) } diff --git a/app/games/create/page.tsx b/app/[lang]/games/create/page.tsx similarity index 79% rename from app/games/create/page.tsx rename to app/[lang]/games/create/page.tsx index d48f595..cea6a3d 100644 --- a/app/games/create/page.tsx +++ b/app/[lang]/games/create/page.tsx @@ -3,8 +3,10 @@ import { createGame } from '@/src/lib/game.service.client' import { useRouter } from 'next/navigation' import { FormEvent, useState } from 'react' import LoadingSpinner from '@/src/components/loading' +import { useTranslationClient } from '@/src/i18n/i18n.client' -export default function Home() { +export default function Home({ params: { lang } }: { params: { lang: string } }) { + const { t } = useTranslationClient(lang) const router = useRouter() const [isLoading, setIsLoading] = useState(false) @@ -15,7 +17,7 @@ export default function Home() { const playerName = String(event.currentTarget.playerName.value) try { const { gameId, playerId } = await createGame({ nbOfPlayers, playerName }) - router.push(`/games/${gameId}?playerId=${playerId}`) + router.push(`/${lang}/games/${gameId}?playerId=${playerId}`) } catch (e) { console.error(e) } finally { @@ -27,11 +29,11 @@ export default function Home() {
- +
- + - New game + {t('new-game')} - {isLoading && } + {isLoading && }
) } diff --git a/app/games/join/page.tsx b/app/[lang]/games/join/page.tsx similarity index 80% rename from app/games/join/page.tsx rename to app/[lang]/games/join/page.tsx index b8d4819..9948d17 100644 --- a/app/games/join/page.tsx +++ b/app/[lang]/games/join/page.tsx @@ -3,8 +3,10 @@ import { joinGame } from '@/src/lib/game.service.client' import { useRouter } from 'next/navigation' import { FormEvent, useState } from 'react' import LoadingSpinner from '@/src/components/loading' +import { useTranslationClient } from '@/src/i18n/i18n.client' -export default function Home() { +export default function Home({ params: { lang } }: { params: { lang: string } }) { + const { t } = useTranslationClient(lang) const router = useRouter() const [isLoading, setIsLoading] = useState(false) @@ -27,21 +29,21 @@ export default function Home() {
- +
- +
- {isLoading && } + {isLoading && }
) } diff --git a/app/globals.css b/app/[lang]/globals.css similarity index 93% rename from app/globals.css rename to app/[lang]/globals.css index 513a6a0..be7f432 100644 --- a/app/globals.css +++ b/app/[lang]/globals.css @@ -4,7 +4,7 @@ :root { --main-font-color: white; - --background-image: url('../src/assets/images/background.jpg'); + --background-image: url('../../src/assets/images/background.jpg'); } body { diff --git a/app/layout.tsx b/app/[lang]/layout.tsx similarity index 51% rename from app/layout.tsx rename to app/[lang]/layout.tsx index f8e1eb7..fab815d 100644 --- a/app/layout.tsx +++ b/app/[lang]/layout.tsx @@ -1,6 +1,12 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' import './globals.css' +import { dir } from 'i18next' +import { languages } from '@/src/i18n/settings' + +export async function generateStaticParams() { + return languages.map((lng) => ({ lng })) +} const inter = Inter({ subsets: ['latin'] }) @@ -9,9 +15,9 @@ export const metadata: Metadata = { description: 'Generated by create next app' } -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ children, params }: { children: React.ReactNode; params: { lang: string } }) { return ( - + {children} ) diff --git a/app/page.tsx b/app/[lang]/page.tsx similarity index 54% rename from app/page.tsx rename to app/[lang]/page.tsx index f79b320..ed82b72 100644 --- a/app/page.tsx +++ b/app/[lang]/page.tsx @@ -1,17 +1,19 @@ import Link from 'next/link' +import { useTranslationServer } from '@/src/i18n/i18n.server' const linkClasses = 'w-96 text-center px-4 py-1 text-4xl text-zinc-300 font-semibold rounded-lg border-4 border-zinc-300 hover:text-slate-950 hover:bg-zinc-300 hover:border-transparent focus:outline-none focus:ring-2 focus:ring-zinc-300 focus:ring-offset-2 basis-1/4 shrink' -export default function Home() { +export default async function Home({ params }: { params: { lang: string } }) { + const { t } = await useTranslationServer(params.lang) return (
- - Create a game + + {t('create-a-game')} - - Join a game + + {t('join-a-game')}
diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..5e33cb1 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,47 @@ +import { NextRequest } from 'next/server.js' +import { cookieName, languages } from '@/src/i18n/settings' +import acceptLanguage from 'accept-language' +import { NextResponse } from 'next/server' + +export function middleware(request: NextRequest) { + // Check if there is any supported locale in the pathname + const { pathname } = request.nextUrl + const pathnameHasLocale = languages.some( + (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` + ) + + const referer = request.headers.get('referer') + if (referer) { + const refererUrl = new URL(referer) + const lngInReferer = languages.find((lang) => refererUrl.pathname.startsWith(`/${lang}`)) + const response = NextResponse.next() + if (lngInReferer) { + response.cookies.set(cookieName, lngInReferer) + } + return response + } + + if (pathnameHasLocale) { + return + } + + let locale = 'en' + if (request.cookies.has(cookieName)) { + const newLocale =acceptLanguage.get(request.cookies.get(cookieName)?.value) + locale = newLocale ?? locale + } + if (!locale){ + const newLocale = acceptLanguage.get(request.headers.get('Accept-Language')) + locale = newLocale ?? locale + } + + request.nextUrl.pathname = `/${locale}${pathname}` + return Response.redirect(request.nextUrl) +} + +export const config = { + matcher: [ + // Skip all internal paths (_next) + '/((?!api|_next/static|_next/image|favicon.ico).*)' + ], +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 776b119..7328c7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,19 @@ "name": "evolution", "version": "0.1.0", "dependencies": { + "accept-language": "^3.0.18", "dotenv": "^16.3.1", + "i18next": "^23.7.20", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-resources-to-backend": "^1.2.0", "mongodb": "^6.3.0", "next": "14.0.4", "pusher": "^5.2.0", "pusher-js": "^8.4.0-rc2", "react": "^18", + "react-cookie": "^7.0.2", "react-dom": "^18", + "react-i18next": "^14.0.1", "uuid": "^9.0.1" }, "devDependencies": { @@ -59,7 +65,6 @@ "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz", "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -580,6 +585,20 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -606,14 +625,12 @@ "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "dev": true + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { "version": "18.2.43", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.43.tgz", "integrity": "sha512-nvOV01ZdBdd/KW6FahSbcNplt2jCJfyWdTos61RYHV+FVv5L/g9AOX1bmbVcWcLFL8+KHQfh1zVIQrud6ihyQA==", - "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -632,8 +649,7 @@ "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "dev": true + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, "node_modules/@types/uuid": { "version": "9.0.7", @@ -773,6 +789,15 @@ "node": ">=6.5" } }, + "node_modules/accept-language": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/accept-language/-/accept-language-3.0.18.tgz", + "integrity": "sha512-sUofgqBPzgfcF20sPoBYGQ1IhQLt2LSkxTnlQSuLF3n5gPEqd5AimbvOvHEi0T1kLMiGVqPWzI5a9OteBRth3A==", + "dependencies": { + "bcp47": "^1.1.2", + "stable": "^0.1.6" + } + }, "node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", @@ -1097,6 +1122,14 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bcp47": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/bcp47/-/bcp47-1.1.2.tgz", + "integrity": "sha512-JnkkL4GUpOvvanH9AZPX38CxhiLsXMBicBY2IAtqiVN8YulGDQybUydWA4W6yAMtw6iShtw+8HEF6cfrTHU+UQ==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1355,6 +1388,14 @@ "typedarray": "^0.0.6" } }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1390,8 +1431,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -2536,6 +2576,60 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "23.7.20", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.7.20.tgz", + "integrity": "sha512-6qykxPpFPuzxC/VlVCXn3JxkHY5VCxf1w+/8Hz+Wxu4ZvfB+m3sbVruJ3C/rDWlE0Z1GCZTR6sBHIx7KGp0yXA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.0.tgz", + "integrity": "sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-resources-to-backend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.0.tgz", + "integrity": "sha512-8f1l03s+QxDmCfpSXCh9V+AFcxAwIp0UaroWuyOx+hmmv8484GcELHs+lnu54FrNij8cDBEXvEwhzZoXsKcVpg==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", @@ -4079,6 +4173,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-cookie": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.0.2.tgz", + "integrity": "sha512-UnW1rZw1VibRdTvV8Ksr0BKKZoajeUxYLE89sIygDeyQgtz6ik89RHOM+3kib36G9M7HxheORggPoLk5DxAK7Q==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.5", + "hoist-non-react-statics": "^3.3.2", + "universal-cookie": "^7.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -4091,11 +4198,31 @@ "react": "^18.2.0" } }, + "node_modules/react-i18next": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.0.1.tgz", + "integrity": "sha512-TMV8hFismBmpMdIehoFHin/okfvgjFhp723RYgIqB4XyhDobVMyukyM3Z8wtTRmajyFMZrBl/OaaXF2P6WjUAw==", + "dependencies": { + "@babel/runtime": "^7.22.5", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/read-cache": { "version": "1.0.0", @@ -4162,8 +4289,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", - "dev": true + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", @@ -4426,6 +4552,12 @@ "os-shim": "^0.1.2" } }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -4898,6 +5030,15 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, + "node_modules/universal-cookie": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.0.2.tgz", + "integrity": "sha512-EC9PA+1nojhJtVnKW2Z7WYah01jgYJApqhX+Y8XU97TnFd7KaoxWTHiTZFtfpfV50jEF1L8V5p64ZxIx3Q67dg==", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^0.6.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -4955,6 +5096,14 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index 1a50327..e6f644e 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,19 @@ "format" ], "dependencies": { + "accept-language": "^3.0.18", "dotenv": "^16.3.1", + "i18next": "^23.7.20", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-resources-to-backend": "^1.2.0", "mongodb": "^6.3.0", "next": "14.0.4", "pusher": "^5.2.0", "pusher-js": "^8.4.0-rc2", "react": "^18", + "react-cookie": "^7.0.2", "react-dom": "^18", + "react-i18next": "^14.0.1", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts index 08d064f..5a24864 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,6 +8,7 @@ dotenv.config() */ export default defineConfig({ testDir: './tests', + timeout: 30000, /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/src/components/card-layout.tsx b/src/components/card-layout.tsx index 8569469..43845ee 100644 --- a/src/components/card-layout.tsx +++ b/src/components/card-layout.tsx @@ -1,11 +1,12 @@ -import React, { FC, KeyboardEventHandler } from 'react' +import React, { FC } from 'react' import { Card } from '@/src/models/card.model' import { usePlayerStatus } from '@/src/hooks/player-status.hook' import Image from 'next/image' -import { getCardImage } from '@/src/lib/card-images.service.client' import { useGameContext } from '@/src/providers/game.provider' import { GameStatus } from '@/src/enums/game.events.enum' +import { getCardImage } from '@/src/lib/card-images.service.client' import { getFeatureDescription, getFeatureName } from '@/src/lib/feature.service.client' +import { useLangContext } from '@/src/providers/lang.provider' interface CardProps { card: Card @@ -14,6 +15,9 @@ interface CardProps { } export const CardLayout: FC = ({ card, index, playCard }) => { + const { + translationHook: { t } + } = useLangContext() const { canDiscardCard, isAddingFoodStage, isFeedingStage } = usePlayerStatus() const { status } = useGameContext() @@ -42,10 +46,10 @@ export const CardLayout: FC = ({ card, index, playCard }) => { const getAriaLabel = (): string => { if (isAddingFoodStage()) { - return `Use the card ${cardName} to add ${card.foodNumber} to the water plan` + return t('add-card-as-food', { name: cardName, foodNumber: card.foodNumber }) } if (canDiscard) { - return `Discard the card ${cardName}` + return t('discard-card', { name: cardName }) } return `${cardName}: ${cardDescription}` } diff --git a/src/components/feature-layout.tsx b/src/components/feature-layout.tsx index ebe536d..5f08958 100644 --- a/src/components/feature-layout.tsx +++ b/src/components/feature-layout.tsx @@ -5,6 +5,7 @@ import { usePlayerStatus } from '@/src/hooks/player-status.hook' import Image from 'next/image' import { getCardImage, getFeatureImage } from '@/src/lib/card-images.service.client' import { getFeatureDescription, getFeatureName } from '@/src/lib/feature.service.client' +import { useLangContext } from '@/src/providers/lang.provider' interface CardProps { feature: Feature @@ -14,6 +15,9 @@ interface CardProps { } export const FeatureLayout: FC = ({ feature, speciesId, speciesIndex, featureIndex }) => { + const { + translationHook: { t } + } = useLangContext() const { removeFeature } = useSpecies() const { isEvolvingStage } = usePlayerStatus() @@ -37,7 +41,7 @@ export const FeatureLayout: FC = ({ feature, speciesId, speciesIndex, > { + const { + translationHook: { t } + } = useLangContext() const { hiddenFoods, amountOfFood } = useGameContext() return (
@@ -14,20 +18,19 @@ export const FoodArea: FC = () => { return ( // biome-ignore lint: Here it is ok to base the key on the index
- Hidden Card + {t('hidden-card')}
) })}
-
+
{`Number
{amountOfFood > 0 && diff --git a/src/components/game.tsx b/src/components/game.tsx index 6b5dee6..18d3108 100644 --- a/src/components/game.tsx +++ b/src/components/game.tsx @@ -23,12 +23,17 @@ import { AddRightSpeciesButton } from '@/src/components/player-species/add-right import { LeftOpponents } from '@/src/components/opponent/left-opponents' import { RightOpponents } from '@/src/components/opponent/right-opponents' import { MiddleOpponent } from '@/src/components/opponent/middle-opponent' +import { useLangContext } from '@/src/providers/lang.provider' +import { FrenchFlagIcon, SettingsIcon } from '@/src/components/svg-icons/french-flag-icon' interface GameProps { game: GameModel } export function Game({ game }: GameProps) { + const { + translationHook: { t } + } = useLangContext() const searchParams = useSearchParams() const { gameId } = useParams<{ gameId: string }>() const playerId = useMemo(() => searchParams.get('playerId'), [searchParams]) @@ -36,16 +41,8 @@ export function Game({ game }: GameProps) { if (!playerId) { throw Error('Player ID must be provided') } - const { - cards, - hiddenFoods, - isPlayerFeedingFirst, - numberOfFoodEaten, - opponents, - speciesList, - updateCards, - updateStatus - } = useGameContext() + const { cards, isPlayerFeedingFirst, numberOfFoodEaten, opponents, speciesList, updateCards, updateStatus } = + useGameContext() const { isAddingFoodStage, isEvolvingStage, isFeedingStage, getCardDiscardMessage } = usePlayerStatus() const { playEvolvingAction } = useSpecies() const { getCard, removeCard } = useCards() @@ -115,12 +112,13 @@ export function Game({ game }: GameProps) { )}
- {isPlayerFeedingFirst && } + {isPlayerFeedingFirst && }

{getCardDiscardMessage()}

-
+
+
    {cards.map((card, index) => { @@ -128,11 +126,31 @@ export function Game({ game }: GameProps) { })}
-
- {`Your - - {numberOfFoodEaten} - +
+
+ {t('player-number-of-points', + + {numberOfFoodEaten} + +
+ + {/**/}
diff --git a/src/components/opponent/left-opponents.tsx b/src/components/opponent/left-opponents.tsx index da2d4d6..e83aea4 100644 --- a/src/components/opponent/left-opponents.tsx +++ b/src/components/opponent/left-opponents.tsx @@ -1,12 +1,16 @@ import { OpponentLayout } from '@/src/components/opponent/opponent-layout' import { FC } from 'react' import { Opponent } from '@/src/models/opponent.model' +import { useLangContext } from '@/src/providers/lang.provider' interface LeftOpponentsProps { opponents: Opponent[] } export const LeftOpponents: FC = ({ opponents }) => { + const { + translationHook: { t } + } = useLangContext() if (opponents.length <= 1) { return null } @@ -22,7 +26,7 @@ export const LeftOpponents: FC = ({ opponents }) => { return (
    {opponentsOnTheLeft.map((opponent, index) => { diff --git a/src/components/opponent/opponent-layout.tsx b/src/components/opponent/opponent-layout.tsx index 75ea614..90737e2 100644 --- a/src/components/opponent/opponent-layout.tsx +++ b/src/components/opponent/opponent-layout.tsx @@ -6,6 +6,7 @@ import { OpponentSpeciesLayout } from '@/src/components/opponent/opponent-specie import Image from 'next/image' import PouchImg from '@/src/assets/images/pouch.png' import { PlayerEatingIcon } from '@/src/components/svg-icons/player-eating-icon' +import { useLangContext } from '@/src/providers/lang.provider' interface OpponentLayoutProps { opponent: Opponent @@ -13,12 +14,15 @@ interface OpponentLayoutProps { } export const OpponentLayout: FC = ({ opponent, opponentIndex }) => { + const { + translationHook: { t } + } = useLangContext() return (
  • {opponent.name} @@ -27,14 +31,17 @@ export const OpponentLayout: FC = ({ opponent, opponentInde
    {`${opponent.name} {opponent.numberOfFoodEaten}
    {opponent.isFirstPlayerToFeed && ( - + )} {opponent.status === GameStatus.FEEDING_SPECIES && } diff --git a/src/components/opponent/opponent-species-layout.tsx b/src/components/opponent/opponent-species-layout.tsx index f035196..08fd88c 100644 --- a/src/components/opponent/opponent-species-layout.tsx +++ b/src/components/opponent/opponent-species-layout.tsx @@ -4,6 +4,7 @@ import { useGameContext } from '@/src/providers/game.provider' import { feedSpecies } from '@/src/lib/species.service' import { FeedMeatIcon } from '@/src/components/svg-icons/feed-meat-icon' import { Species } from '@/src/models/species.model' +import { useLangContext } from '@/src/providers/lang.provider' interface OpponentLayoutProps { opponentIndex: number @@ -12,6 +13,9 @@ interface OpponentLayoutProps { } export const OpponentSpeciesLayout: FC = ({ opponentIndex, speciesIndex, species }) => { + const { + translationHook: { t } + } = useLangContext() const { carnivoreFeedingData, gameId, playerId } = useGameContext() const canBeEaten = carnivoreFeedingData.preyIds.includes(species.id) @@ -30,20 +34,22 @@ export const OpponentSpeciesLayout: FC = ({ opponentIndex,
  • {species.size} {canBeEaten && ( )} {species.population} diff --git a/src/components/opponent/right-opponents.tsx b/src/components/opponent/right-opponents.tsx index 3d74f51..b4ec07b 100644 --- a/src/components/opponent/right-opponents.tsx +++ b/src/components/opponent/right-opponents.tsx @@ -1,12 +1,16 @@ import { OpponentLayout } from '@/src/components/opponent/opponent-layout' import { FC } from 'react' import { Opponent } from '@/src/models/opponent.model' +import { useLangContext } from '@/src/providers/lang.provider' interface RightOpponentsProps { opponents: Opponent[] } export const RightOpponents: FC = ({ opponents }) => { + const { + translationHook: { t } + } = useLangContext() if (opponents.length <= 1) { return null } @@ -22,7 +26,7 @@ export const RightOpponents: FC = ({ opponents }) => { return (

      {opponentsOnTheRight.map((opponent, index) => { diff --git a/src/components/player-species/add-left-species-button.tsx b/src/components/player-species/add-left-species-button.tsx index a8dffba..0f05218 100644 --- a/src/components/player-species/add-left-species-button.tsx +++ b/src/components/player-species/add-left-species-button.tsx @@ -2,8 +2,12 @@ import { FC } from 'react' import { EVOLVING_STAGES, useGameContext } from '@/src/providers/game.provider' import { usePlayerStatus } from '@/src/hooks/player-status.hook' import { AddNewSpeciesIcon } from '@/src/components/svg-icons/add-new-species-icon' +import { useLangContext } from '@/src/providers/lang.provider' export const AddLeftSpeciesButton: FC = () => { + const { + translationHook: { t } + } = useLangContext() const { isEvolvingStage } = usePlayerStatus() const { updateStatus, status } = useGameContext() @@ -21,7 +25,7 @@ export const AddLeftSpeciesButton: FC = () => { updateStatus(EVOLVING_STAGES.ADD_LEFT_SPECIES) }} > - + ) } diff --git a/src/components/player-species/add-right-species-button.tsx b/src/components/player-species/add-right-species-button.tsx index e7e93ab..bae9461 100644 --- a/src/components/player-species/add-right-species-button.tsx +++ b/src/components/player-species/add-right-species-button.tsx @@ -2,8 +2,12 @@ import { FC } from 'react' import { usePlayerStatus } from '@/src/hooks/player-status.hook' import { EVOLVING_STAGES, useGameContext } from '@/src/providers/game.provider' import { AddNewSpeciesIcon } from '@/src/components/svg-icons/add-new-species-icon' +import { useLangContext } from '@/src/providers/lang.provider' export const AddRightSpeciesButton: FC = () => { + const { + translationHook: { t } + } = useLangContext() const { updateStatus, status } = useGameContext() const { isEvolvingStage } = usePlayerStatus() if (!isEvolvingStage()) { @@ -20,7 +24,7 @@ export const AddRightSpeciesButton: FC = () => { updateStatus(EVOLVING_STAGES.ADD_RIGHT_SPECIES) }} > - + ) } diff --git a/src/components/player-species/add-species-features-button.tsx b/src/components/player-species/add-species-features-button.tsx index 5b4e18a..f280b9d 100644 --- a/src/components/player-species/add-species-features-button.tsx +++ b/src/components/player-species/add-species-features-button.tsx @@ -3,6 +3,7 @@ import { Species } from '@/src/models/species.model' import { usePlayerStatus } from '@/src/hooks/player-status.hook' import { PlusIcon } from '@/src/components/svg-icons/plus-icon' import { EVOLVING_STAGES, useGameContext } from '@/src/providers/game.provider' +import { useLangContext } from '@/src/providers/lang.provider' interface AddSpeciesFeatureButtonProps { index: number @@ -10,6 +11,9 @@ interface AddSpeciesFeatureButtonProps { } export const AddSpeciesFeatureButton: FC = ({ index, species }) => { + const { + translationHook: { t } + } = useLangContext() const { updateStatus, updateSelectedSpecies, status, selectedSpecies } = useGameContext() const { isEvolvingStage } = usePlayerStatus() @@ -31,7 +35,7 @@ export const AddSpeciesFeatureButton: FC = ({ inde updateStatus(EVOLVING_STAGES.ADD_SPECIES_FEATURE) }} > - + ) } diff --git a/src/components/player-species/feed-species-button.tsx b/src/components/player-species/feed-species-button.tsx index 4baebfe..9336443 100644 --- a/src/components/player-species/feed-species-button.tsx +++ b/src/components/player-species/feed-species-button.tsx @@ -9,6 +9,7 @@ import { CarnivoreWaitingIcon } from '@/src/components/svg-icons/carnivore-waiti import { CarnivoreAttackingIcon } from '@/src/components/svg-icons/carnivore-attacking-icon' import { GameStatus } from '@/src/enums/game.events.enum' import { FoodIcon } from '@/src/components/svg-icons/food-icon' +import { useLangContext } from '@/src/providers/lang.provider' interface FeedSpeciesButtonProps { gameId: string @@ -18,6 +19,9 @@ interface FeedSpeciesButtonProps { } export const FeedSpeciesButton: FC = ({ index, gameId, playerId, species }) => { + const { + translationHook: { t } + } = useLangContext() const { carnivoreFeedingData, updateStatus } = useGameContext() const { isFeedingStage } = usePlayerStatus() @@ -40,7 +44,7 @@ export const FeedSpeciesButton: FC = ({ index, gameId, p {canBeEaten ? ( ) : ( @@ -55,6 +59,9 @@ interface FeedCarnivoreButtonProps { } const FeedCarnivoreButton: FC = ({ index, species }) => { + const { + translationHook: { t } + } = useLangContext() const { carnivoreFeedingData, updateCarnivoreFeedingData } = useGameContext() const isCurrentlyFeeding = carnivoreFeedingData.carnivoreId === species.id @@ -76,13 +83,13 @@ const FeedCarnivoreButton: FC = ({ index, species }) = {species.preyIds.length > 0 ? ( ) : ( - Go vegan + {t('go-vegan')} )} ) @@ -96,6 +103,9 @@ interface FeedPlantsButtonProps { } const FeedPlantsButton: FC = ({ gameId, playerId, species, index }) => { + const { + translationHook: { t } + } = useLangContext() if (isCarnivore(species) || species.foodEaten >= species.population) { return null } @@ -108,7 +118,7 @@ const FeedPlantsButton: FC = ({ gameId, playerId, species ) } const IncreaseSpeciesPopulationLabel: FC = ({ index, species }) => { + const { + translationHook: { t } + } = useLangContext() return ( {species.population} diff --git a/src/components/player-species/species-size-element.tsx b/src/components/player-species/species-size-element.tsx index a4c46ce..a1e7f2b 100644 --- a/src/components/player-species/species-size-element.tsx +++ b/src/components/player-species/species-size-element.tsx @@ -3,6 +3,7 @@ import { Species } from '@/src/models/species.model' import { usePlayerStatus } from '@/src/hooks/player-status.hook' import { EVOLVING_STAGES, useGameContext } from '@/src/providers/game.provider' import { PlusIcon } from '@/src/components/svg-icons/plus-icon' +import { useLangContext } from '@/src/providers/lang.provider' interface SpeciesSizeElementProps { index: number @@ -26,6 +27,9 @@ export const SpeciesSizeElement: FC = ({ index, species } const IncreaseSpeciesSizeButton: FC = ({ index, species }) => { + const { + translationHook: { t } + } = useLangContext() const { updateStatus, updateSelectedSpecies, status, selectedSpecies } = useGameContext() const isAnimated = @@ -42,17 +46,20 @@ const IncreaseSpeciesSizeButton: FC = ({ index, species > - + ) } const IncreaseSpeciesSizeLabel: FC = ({ index, species }) => { + const { + translationHook: { t } + } = useLangContext() return ( {species.size} diff --git a/src/components/svg-icons/french-flag-icon.tsx b/src/components/svg-icons/french-flag-icon.tsx new file mode 100644 index 0000000..bc354e7 --- /dev/null +++ b/src/components/svg-icons/french-flag-icon.tsx @@ -0,0 +1,24 @@ +import { FC } from 'react' + +export const FrenchFlagIcon: FC = () => { + return ( + + ) +} diff --git a/src/components/svg-icons/player-eating-icon.tsx b/src/components/svg-icons/player-eating-icon.tsx index c86cd3e..7c5fceb 100644 --- a/src/components/svg-icons/player-eating-icon.tsx +++ b/src/components/svg-icons/player-eating-icon.tsx @@ -1,16 +1,20 @@ import { FC } from 'react' +import { useLangContext } from '@/src/providers/lang.provider' interface PlayerEatingIconProps { name: string } export const PlayerEatingIcon: FC = ({ name }) => { + const { + translationHook: { t } + } = useLangContext() return ( boolean @@ -12,6 +13,9 @@ interface PlayerStatusResult { } export const usePlayerStatus = (): PlayerStatusResult => { + const { + translationHook: { t } + } = useLangContext() const { status, selectedSpecies } = useGameContext() const isAddingFoodStage = (): boolean => { @@ -36,27 +40,27 @@ export const usePlayerStatus = (): PlayerStatusResult => { const getCardDiscardMessage = (): string => { switch (status) { case EVOLVING_STAGES.ADD_LEFT_SPECIES: - return 'Choose the species to add to the left' + return t('choose-species-add-left') case EVOLVING_STAGES.ADD_RIGHT_SPECIES: - return 'Choose the species to add to the right' + return t('choose-species-add-right') case GameStatus.CHOOSING_EVOLVING_ACTION: - return 'Choose an action to evolve your species or finish your turn' + return t('choose-action-or-finish') case GameStatus.ADDING_FOOD_TO_WATER_PLAN: - return 'Discard a card to add food to the water plan' + return t('discard-card-to-add-food') case EVOLVING_STAGES.INCREMENT_SPECIES_SIZE: - return 'Discard a card to increase the selected species size' + return t('discard-card-to-increase-size') case EVOLVING_STAGES.INCREMENT_SPECIES_POPULATION: - return 'Discard a card to increase the selected species population' + return t('discard-card-to-increase-population') case EVOLVING_STAGES.ADD_SPECIES_FEATURE: - return 'Choose the card to add as a feature for the selected species' + return t('choose-card-to-add-as-feature') case GameStatus.FEEDING_SPECIES: - return 'Choose the species you would like to feed' + return t('choose-species-to-feed') case GameStatus.WAITING_FOR_PLAYERS_TO_JOIN: - return 'Waiting for other players to join' + return t('waiting-for-other-players-to-join') case GameStatus.WAITING_FOR_PLAYERS_TO_FEED: - return 'Waiting for other players to feed' + return t('waiting-for-other-players-to-feed') case GameStatus.WAITING_FOR_PLAYERS_TO_FINISH_EVOLVING: - return 'Waiting for other players to finish evolving' + return t('waiting-for-other-players-to-evolve') default: console.warn(`Adding an action message has not been supported for the action ${status} `) return '' diff --git a/src/i18n/i18n.client.ts b/src/i18n/i18n.client.ts new file mode 100644 index 0000000..81f16c7 --- /dev/null +++ b/src/i18n/i18n.client.ts @@ -0,0 +1,54 @@ +'use client' + +import i18next from 'i18next' +import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next' +import resourcesToBackend from 'i18next-resources-to-backend' +import { cookieName, defaultNS, getOptions, languages } from './settings' +import LanguageDetector from 'i18next-browser-languagedetector' +import { useEffect, useState } from 'react' +import { useCookies } from 'react-cookie' + +if (!i18next.isInitialized) { + i18next + .use(initReactI18next) + .use(LanguageDetector) + .use(resourcesToBackend((language: string) => import(`./locales/${language}/${defaultNS}.json`))) + .init({ + ...getOptions(), + lng: undefined, // let detect the language on client side + detection: { + order: ['path', 'htmlTag', 'cookie', 'navigator'] + }, + preload: languages + }) +} + +export function useTranslationClient(lng?: string) { + const [cookies, setCookie] = useCookies([cookieName]) + const translationHook = useTranslationOrg(defaultNS) + const { i18n } = translationHook + const runsOnServerSide = typeof window === 'undefined' + + const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage) + + if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) { + i18n.changeLanguage(lng) + } + + useEffect(() => { + if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) { + return + } + if (activeLng !== i18n.resolvedLanguage) { + setActiveLng(i18n.resolvedLanguage) + } + if (lng && i18n.resolvedLanguage !== lng) { + i18n.changeLanguage(lng) + } + if (cookies.i18next !== lng) { + setCookie(cookieName, lng, { path: '/' }) + } + }, [activeLng, cookies.i18next, i18n, lng, runsOnServerSide, setCookie]) + + return translationHook +} diff --git a/src/i18n/i18n.server.ts b/src/i18n/i18n.server.ts new file mode 100644 index 0000000..ef0cd56 --- /dev/null +++ b/src/i18n/i18n.server.ts @@ -0,0 +1,21 @@ +import { createInstance } from 'i18next' +import { initReactI18next } from 'react-i18next/initReactI18next' +import resourcesToBackend from 'i18next-resources-to-backend' +import { getOptions } from '@/src/i18n/settings' + +const initI18next = async (lng = 'en') => { + const i18nInstance = createInstance() + await i18nInstance + .use(initReactI18next) + .use(resourcesToBackend((language: string) => import(`./locales/${language}/translation.json`))) + .init(getOptions(lng)) + return i18nInstance +} + +export async function useTranslationServer(lng: string) { + const i18nextInstance = await initI18next(lng) + return { + t: i18nextInstance.getFixedT(lng, 'translation'), + i18n: i18nextInstance + } +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json new file mode 100644 index 0000000..d11b333 --- /dev/null +++ b/src/i18n/locales/en/translation.json @@ -0,0 +1,65 @@ +{ + "add-card-as-food": "Use the card {{name}} to add {{foodNumber}} to the water plan", + "add-feature": "Add feature to species at position {{position}}", + "add-species-left": "Add a new species to the left", + "add-species-right": "Add a new species to the right", + "carnivore-description": "This species must attack other species for food and it can never eat Plants.", + "carnivore-feeding-cancel": "Cancel feeding of the carnivore", + "carnivore-name": "Carnivore", + "climbing-description": "A carnivore must have the Climbing feature to attack it.", + "climbing-name": "Climbing", + "create-a-game": "Create a game", + "choose-action-or-finish": "Choose an action to evolve your species or finish your turn", + "choose-card-to-add-as-feature": "Choose the card to add as a feature for the selected species", + "choose-species-add-left": "Choose the species to add to the left", + "choose-species-add-right": "Choose the species to add to the right", + "choose-species-to-feed": "Choose the species you would like to feed", + "creating-game": "Creating game...", + "digger-description": "This species can't be attacked if its food number equals its population.", + "digger-name": "Digger", + "discard-card": "Discard the card {{name}}", + "discard-card-to-add-food": "Discard a card to add food to the water plan", + "discard-card-to-increase-population": "Discard a card to increase the selected species population", + "discard-card-to-increase-size": "Discard a card to increase the selected species size", + "eat-own-species": "Eat your own species at index {{index}}", + "feed-carnivore": "Feed carnivore at index {{index}}", + "feed-plant": "Feed plants to species at index {{index}}", + "fertile-description": "Before revealing food cards, this species population increases by one if there is food left in the water plan.", + "fertile-name": "Fertile", + "forager-description": "Each time this species eats one or more Plants, it takes 1 additional Plant from the same source.", + "forager-name": "Forager", + "game-code": "Game code:", + "go-vegan": "Go vegan", + "herd-description": "A carnivore must have a superior population to attack it.", + "herd-name": "Herd", + "hidden-card": "Hidden card", + "increase-population": "Increase population of species at position {{index}}", + "increase-size": "Increase size of species at position {{index}}", + "join-game": "Join game", + "join-a-game": "Join a game", + "joining-game": "Joining game...", + "left-opponents": "Opponents on the left", + "long-neck-description": "Before revealing food cards, pick one food from the food stash (not the water plan).", + "long-neck-name": "Long neck", + "new-game": "New game", + "number-of-food-food-area": "Number of food on the food area: {{amountOfFood}}", + "number-of-players": "Number of players:", + "opponent-eat-species": "Eat the species at index {{speciesIndex}} of opponent at index {{opponentIndex}}", + "opponent-feeding": "{{name}} is currently feeding", + "opponent-first-to-feed": "The player {{name}} is the first player to feed", + "opponent-index-name": "Opponent's at index {{index}} name is {{name}}", + "opponent-number-points": "{{name}} number of points: {{points}}", + "opponent-species-population": "`Species at index {{speciesIndex}} of opponent at index {{opponentIndex}} population: {{population}}", + "opponent-species-size": "Species at index {{speciesIndex}} of opponent at index {{opponentIndex}} size: {{size}}", + "player-is-first-to-feed": "You are the first player to feed", + "player-number-of-points": "Your number of points: {{numberOfFoodEaten}}", + "remove-feature": "Remove feature {{name}} of species at index {{index}}", + "right-opponents": "Opponents on the right", + "species-fed-population": "Species at index {{index}} fed population: {{foodEaten}}/{{population}}", + "species-population": "Species at index {{index}} population: {{population}}", + "species-size": "Species at index {{index}} size: {{size}}", + "your-name": "Your name:", + "waiting-for-other-players-to-evolve": "Waiting for other players to finish evolving", + "waiting-for-other-players-to-feed": "Waiting for other players to feed", + "waiting-for-other-players-to-join": "Waiting for other players to join" +} diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json new file mode 100644 index 0000000..4e50314 --- /dev/null +++ b/src/i18n/locales/fr/translation.json @@ -0,0 +1,65 @@ +{ + "add-card-as-food": "Utiliser la carte {{name}} pour ajouter de la nourriture {{foodNumber}} au plan d'eau", + "add-feature": "Ajouter un trait à la position {{position}}", + "add-species-left": "Ajouter une nouvelle espèce à gauche", + "add-species-right": "Ajouter une nouvelle espèce à droite", + "carnivore-description": "Cette espèces doit attaquer d'autres espèces pour se nourrir et ne peut jamais manger des plantes.", + "carnivore-feeding-cancel": "Annuler l'attaque du carnivore", + "carnivore-name": "Carnivore", + "climbing-description": "Un carnivore doit avoir le trait escalade pour attaquer cette espèce", + "climbing-name": "Escalade", + "create-a-game": "Créer une partie", + "choose-action-or-finish": "Choississez une action pour faire évoluer vos espèce, ou finissez votre tour", + "choose-card-to-add-as-feature": "Choississez une carte à ajouter en tant que trait pour l'espèce sélectionnée", + "choose-species-add-left": "Choississez l'espèce à ajouter à gauche", + "choose-species-add-right": "Choississez l'espèce à ajouter à droite", + "choose-species-to-feed": "Choississez l'espèce que vous souhaitez nourrir", + "creating-game": "Création de la partie en cours...", + "digger-description": "Cette espèce ne peut pas être attaqué si elle a mangé autant de nourriture qu'elle a de population.", + "digger-name": "Fouisseuse", + "discard-card": "Défausser la carte {{name}}", + "discard-card-to-add-food": "Défaussez une carte pour ajouter de la nourriture au plan d'eau", + "discard-card-to-increase-population": "Défaussez une carte pour incrémenter la population de l'espèce sélectionnée", + "discard-card-to-increase-size": "Défaussez une carte pour incrémenter la taille de l'espèce sélectionnée", + "eat-own-species": "Manger votre propre espèce à l'index {{index}}", + "feed-carnivore": "Nourrir le carnivore à l'index {{index}}", + "feed-plant": "Nourrir des plantes à l'espèce à l'index {{index}}", + "fertile-description": "Avant de révèler les cartes nourritures, la population de cette espèce augmente de 1 s'il reste de la nourriture dans le plan d'eau.", + "fertile-name": "Fertile", + "forager-description": "A chaque fois que cette espèce mange une plante, elle en prend deux depuis la même source de nourriture.", + "forager-name": "Fourageuse", + "game-code": "Code de la partie:", + "go-vegan": "Go vegan", + "herd-description": "Un carnivore doit avoir une population supérieure pour l'attaquer.", + "herd-name": "Horde", + "hidden-card": "Carte cachée", + "increase-population": "Augmenter la population de l'espèce à l'index {{index}}", + "increase-size": "Augmenter la taille de l'espèce à l'index {{index}}", + "join-game": "Rejoindre la partie", + "join-a-game": "Rejoindre une partie", + "joining-game": "Connexion à la partie...", + "left-opponents": "Adversaires sur la gauche", + "long-neck-description": "Avant de révéler les cartes de nourriture, prend une nourriture depuis la réserver (et pas du plan d'eau).", + "long-neck-name": "Long cou", + "new-game": "Nouvelle partie", + "number-of-food-food-area": "Nombre de nourriture sur le plan d'eau: {{amountOfFood}}", + "number-of-players": "Nombre de joueurs:", + "opponent-eat-species": "Manger l'espèce à l'index {{speciesIndex}} de l'adversaire à l'index {{opponentIndex}}", + "opponent-feeding": "{{name}} est entrain de se nourrir", + "opponent-first-to-feed": "L'adversaire {{name}} est le premier à se nourrir", + "opponent-index-name": "L'adversaire à l'index {{index}} est {{name}}", + "opponent-number-points": "{{name}} nombre de points: {{points}}", + "opponent-species-population": "`Population de l'espèce à l'index {{speciesIndex}} de l'adversaire à l'index {{opponentIndex}}: {{population}}", + "opponent-species-size": "Taille de l'espèce à l'index {{speciesIndex}} de l'adversaire à l'index {{opponentIndex}}: {{size}}", + "player-is-first-to-feed": "Vous êtes le premier joueur à vous nourrir", + "player-number-of-points": "Vous avez {{numberOfFoodEaten}} points", + "remove-feature": "Supprimer le trait {{name}} de l'espèce à l'index {{index}}", + "right-opponents": "Adversaire sur la droite", + "species-fed-population": "Population nourrie de l'espèce à l'index {{index}}: {{foodEaten}}/{{population}}", + "species-population": "Population de l'espèce à l'index {{index}}: {{population}}", + "species-size": "Taille de l'espèce à l'index {{index}}: {{size}}", + "your-name": "Votre nom:", + "waiting-for-other-players-to-evolve": "En attente que les autres joueurs finissent d'évoluer leurs espèces", + "waiting-for-other-players-to-feed": "En attente que les autres joueurs se nourrissent", + "waiting-for-other-players-to-join": "En attente que tous les joueurs aient rejoint la partie" +} diff --git a/src/i18n/settings.ts b/src/i18n/settings.ts new file mode 100644 index 0000000..a96f253 --- /dev/null +++ b/src/i18n/settings.ts @@ -0,0 +1,16 @@ +export const fallbackLng = 'en' +export const languages = [fallbackLng, 'fr'] +export const defaultNS = 'translation' +export const cookieName = 'i18next' + +export function getOptions(lng = fallbackLng, ns = defaultNS) { + return { + // debug: true, + supportedLngs: languages, + fallbackLng, + lng, + fallbackNS: defaultNS, + defaultNS, + ns + } +} diff --git a/src/lib/feature.service.client.ts b/src/lib/feature.service.client.ts index bcc98a6..6dc5ce2 100644 --- a/src/lib/feature.service.client.ts +++ b/src/lib/feature.service.client.ts @@ -1,21 +1,22 @@ import { FeatureKey } from '@/src/enums/feature-key.enum' +import i18next from 'i18next' export const getFeatureDescription = (featureKey: FeatureKey): string => { switch (featureKey) { case FeatureKey.LONG_NECK: - return 'Before revealing food cards, pick one food from the food stash (not the water plan).' + return i18next.t('long-neck-description') case FeatureKey.FERTILE: - return 'Before revealing food cards, this species population increases by one if there is food left in the water plan.' + return i18next.t('fertile-description') case FeatureKey.FORAGER: - return 'Each time this species eats one or more Plants, it takes 1 additional Plant from the same source.' + return i18next.t('forager-description') case FeatureKey.CARNIVORE: - return 'This species must attack other species for food and it can never eat Plants.' + return i18next.t('carnivore-description') case FeatureKey.CLIMBING: - return 'A carnivore must have the Climbing feature to attack it.' + return i18next.t('climbing-description') case FeatureKey.DIGGER: - return "This species can't be attacked if its food number equals its population." + return i18next.t('digger-description') case FeatureKey.HERD: - return 'A carnivore must have a superior population to attack it.' + return i18next.t('herd-description') default: return '' } @@ -24,19 +25,19 @@ export const getFeatureDescription = (featureKey: FeatureKey): string => { export const getFeatureName = (featureKey: FeatureKey): string => { switch (featureKey) { case FeatureKey.LONG_NECK: - return 'Long neck' + return i18next.t('long-neck-name') case FeatureKey.FERTILE: - return 'Fertile' + return i18next.t('fertile-name') case FeatureKey.FORAGER: - return 'Forager' + return i18next.t('forager-name') case FeatureKey.CARNIVORE: - return 'Carnivore' + return i18next.t('carnivore-name') case FeatureKey.CLIMBING: - return 'Climbing' + return i18next.t('climbing-name') case FeatureKey.DIGGER: - return 'Digger' + return i18next.t('digger-name') case FeatureKey.HERD: - return 'Herd' + return i18next.t('herd-name') default: return '' } diff --git a/src/lib/game.service.client.ts b/src/lib/game.service.client.ts index e234252..36157cf 100644 --- a/src/lib/game.service.client.ts +++ b/src/lib/game.service.client.ts @@ -1,5 +1,3 @@ -import { Game } from '@/src/models/game.model' - export const createGame = async ({ nbOfPlayers, playerName diff --git a/src/providers/lang.provider.tsx b/src/providers/lang.provider.tsx new file mode 100644 index 0000000..279bb7d --- /dev/null +++ b/src/providers/lang.provider.tsx @@ -0,0 +1,22 @@ +'use client' +import { createContext, FunctionComponent, PropsWithChildren, useContext } from 'react' +import { UseTranslationResponse } from 'react-i18next' +import { useTranslationClient } from '@/src/i18n/i18n.client' +import { useParams } from 'next/navigation' + +interface LangContextResult { + translationHook: UseTranslationResponse<'translation', undefined> +} + +const LangContext = createContext({} as LangContextResult) + +export const LangProvider: FunctionComponent = ({ children }) => { + const params = useParams<{ lang: string }>() + const translationHook = useTranslationClient(params.lang) + + return {children} +} + +export function useLangContext(): LangContextResult { + return useContext(LangContext) +} diff --git a/tests/pages/game-page/1-game-page.spec.ts b/tests/pages/game-page/1-game-page.spec.ts index 773e29a..ad213d5 100644 --- a/tests/pages/game-page/1-game-page.spec.ts +++ b/tests/pages/game-page/1-game-page.spec.ts @@ -36,24 +36,24 @@ test('should be able to play a game round with 2 players', async ({ page: firstP await expect(firstPlayerPage.getByLabel('You are the first player to feed')).toBeVisible() await checkPlayerInitialLayout(secondPlayerPage, 'Aude', false) - await addCardAsFood(firstPlayerPage, 1, 0, 4, firstPlayer.cards[0]) + await addCardAsFood(firstPlayerPage, 1, 0, 4, 'Long neck', 4) await assertNumberOfHiddenFood(secondPlayerPage, 1) await assertNumberOfFoodInWaterPlan(firstPlayerPage, 0) await assertNumberOfFoodInWaterPlan(secondPlayerPage, 0) - await addSpeciesToTheLeft(firstPlayerPage, 1, 3, firstPlayer.cards[1]) - await increaseSpeciesSize(firstPlayerPage, 1, 2, 2, firstPlayer.cards[2]) - await increaseSpeciesPopulation(firstPlayerPage, 1, 2, 1, firstPlayer.cards[3]) + await addSpeciesToTheLeft(firstPlayerPage, 1, 3, 'Fertile') + await increaseSpeciesSize(firstPlayerPage, 1, 2, 2, 'Forager') + await increaseSpeciesPopulation(firstPlayerPage, 1, 2, 1, 'Carnivore') await finishTurnEvolvingAndWaitForOthersToEvolve(firstPlayerPage) await checkOpponentSpecies(secondPlayerPage, 0, 0, 1, 1) await assertNumberOfFoodInWaterPlan(firstPlayerPage, 0) await assertNumberOfFoodInWaterPlan(secondPlayerPage, 0) - await addCardAsFood(secondPlayerPage, 1, 1, 4, secondPlayer.cards[3]) + await addCardAsFood(secondPlayerPage, 1, 1, 4, 'Carnivore', 1) await assertNumberOfHiddenFood(firstPlayerPage, 2) - await addSpeciesToTheRight(secondPlayerPage, 1, 3, secondPlayer.cards[1]) - await addSpeciesFeature(secondPlayerPage, 1, 2, 2, secondPlayer.cards[0]) + await addSpeciesToTheRight(secondPlayerPage, 1, 3, 'Fertile') + await addSpeciesFeature(secondPlayerPage, 1, 2, 2, 'Long neck') await finishTurnEvolvingAndWaitForOthersToFeed(secondPlayerPage) await checkOpponentSpecies(secondPlayerPage, 0, 0, 1, 1) await checkOpponentSpecies(secondPlayerPage, 0, 1, 2, 2) diff --git a/tests/pages/home-page.spec.ts b/tests/pages/home-page.spec.ts index af22cfd..a5ff08c 100644 --- a/tests/pages/home-page.spec.ts +++ b/tests/pages/home-page.spec.ts @@ -5,7 +5,7 @@ test('has button to redirect to create game page', async ({ page }) => { await page.getByRole('link', { name: 'Create a game' }).click() - await expect(page).toHaveURL('http://localhost:3000/games/create') + await expect(page).toHaveURL('http://localhost:3000/en/games/create') }) test('has button to redirect to join game page', async ({ page }) => { @@ -13,5 +13,5 @@ test('has button to redirect to join game page', async ({ page }) => { await page.getByRole('link', { name: 'Join a game' }).click() - await expect(page).toHaveURL('http://localhost:3000/games/join') + await expect(page).toHaveURL('http://localhost:3000/en/games/join') }) diff --git a/tests/utils/cards.util.ts b/tests/utils/cards.util.ts index ef58714..13ec4df 100644 --- a/tests/utils/cards.util.ts +++ b/tests/utils/cards.util.ts @@ -3,13 +3,11 @@ import { Card } from '@/src/models/card.model' import { FeatureKey } from '@/src/enums/feature-key.enum' import { getFeatureName } from '@/src/lib/feature.service.client' -export const getCardToAddAsFood = async (page: Page, card: Card): Promise => { - const cardName = getFeatureName(card.featureKey) - return page.getByRole('listitem', { name: `Use the card ${cardName} to add ${card.foodNumber} to the water plan` }) +export const getCardToAddAsFood = async (page: Page, cardName: string, foodNumber: number): Promise => { + return page.getByRole('listitem', { name: `Use the card ${cardName} to add ${foodNumber} to the water plan` }) } -export const getCardToDiscard = async (page: Page, card: Card): Promise => { - const cardName = getFeatureName(card.featureKey) +export const getCardToDiscard = async (page: Page, cardName: string): Promise => { return page.getByRole('listitem', { name: `Discard the card ${cardName}` }) } @@ -22,11 +20,12 @@ export const addCardAsFood = async ( numberOfSpecies: number, numberOfAddedFood: number, numberOfCards: number, - card: Card + cardName: string, + foodNumber: number ): Promise => { await expect(page.getByRole('button', { name: 'Add as food' })).toBeHidden() - const cardToAdd = await getCardToAddAsFood(page, card) + const cardToAdd = await getCardToAddAsFood(page, cardName, foodNumber) await expect(cardToAdd).toBeVisible() await expect(page.getByTestId(`hidden-food-${numberOfAddedFood}`)).not.toBeAttached() diff --git a/tests/utils/species.util.ts b/tests/utils/species.util.ts index de71667..37f603b 100644 --- a/tests/utils/species.util.ts +++ b/tests/utils/species.util.ts @@ -1,5 +1,4 @@ import { expect, Page } from '@playwright/test' -import { Card } from '@/src/models/card.model' import { assertNumberOfCards, getCardToDiscard } from '@/tests/utils/cards.util' export const assertNumberOfSpecies = async (page: Page, numberOfSpecies: number): Promise => { @@ -28,7 +27,7 @@ const addSpecies = async ( page: Page, numberOfSpecies: number, numberOfCards: number, - card: Card, + cardName: string, buttonLabel: string ): Promise => { await assertNumberOfSpecies(page, numberOfSpecies) @@ -36,7 +35,7 @@ const addSpecies = async ( await page.getByRole('button', { name: buttonLabel }).click() - const cardToDiscard = await getCardToDiscard(page, card) + const cardToDiscard = await getCardToDiscard(page, cardName) await cardToDiscard.click() await assertNumberOfSpecies(page, numberOfSpecies + 1) @@ -50,18 +49,18 @@ export const addSpeciesToTheLeft = async ( page: Page, numberOfSpecies: number, numberOfCards: number, - card: Card + cardName: string ): Promise => { - await addSpecies(page, numberOfSpecies, numberOfCards, card, 'Add a new species to the left') + await addSpecies(page, numberOfSpecies, numberOfCards, cardName, 'Add a new species to the left') } export const addSpeciesToTheRight = async ( page: Page, numberOfSpecies: number, numberOfCards: number, - card: Card + cardName: string ): Promise => { - await addSpecies(page, numberOfSpecies, numberOfCards, card, 'Add a new species to the right') + await addSpecies(page, numberOfSpecies, numberOfCards, cardName, 'Add a new species to the right') } export const addSpeciesFeature = async ( @@ -69,7 +68,7 @@ export const addSpeciesFeature = async ( speciesIndex: number, numberOfSpecies: number, numberOfCards: number, - featureToAdd: Card + cardName: string ): Promise => { await assertNumberOfSpecies(page, numberOfSpecies) await assertNumberOfCards(page, numberOfCards) @@ -77,7 +76,7 @@ export const addSpeciesFeature = async ( await page.getByRole('img', { name: `Add feature to species at position ${speciesIndex + 1}` }).click() - const cardToDiscard = await getCardToDiscard(page, featureToAdd) + const cardToDiscard = await getCardToDiscard(page, cardName) await cardToDiscard.click() @@ -93,14 +92,14 @@ export const increaseSpeciesSize = async ( speciesIndex: number, numberOfSpecies: number, numberOfCards: number, - cardToDiscard: Card + cardName: string ): Promise => { await assertNumberOfSpecies(page, numberOfSpecies) await assertNumberOfCards(page, numberOfCards) await page.getByRole('img', { name: `Increase size of species at position ${speciesIndex + 1}` }).click() - const card = await getCardToDiscard(page, cardToDiscard) + const card = await getCardToDiscard(page, cardName) await card.click() @@ -115,14 +114,14 @@ export const increaseSpeciesPopulation = async ( speciesIndex: number, numberOfSpecies: number, numberOfCards: number, - cardToDiscard: Card + cardName: string ): Promise => { await assertNumberOfSpecies(page, numberOfSpecies) await assertNumberOfCards(page, numberOfCards) await page.getByRole('img', { name: `Increase population of species at position ${speciesIndex + 1}` }).click() - const card = await getCardToDiscard(page, cardToDiscard) + const card = await getCardToDiscard(page, cardName) await card.click()