From df3a73df887699a05313cecf757563114f30e367 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 30 Jan 2026 13:13:42 +0100 Subject: [PATCH] canonical URL, a11y, and LCP improvements add metadata, moving splitting client and server component when needed as metadata is not allowed in a client component reusable baseUrl set x-url header for use in canonical urls for each page remove hardcoded title tag remove user-scalable=no to pass a11y checks, since it is ignored anyway by browsers now LCP set as priority loading --- .../(main)/auth/sign-in/SignInClient.tsx | 43 +++ src/app/[locale]/(main)/auth/sign-in/page.tsx | 60 ++-- src/app/[locale]/(main)/clubs/page.tsx | 19 ++ src/app/[locale]/(main)/elo/EloClient.tsx | 288 +++++++++++++++++ src/app/[locale]/(main)/elo/page.tsx | 303 ++---------------- src/app/[locale]/(main)/tournaments/page.tsx | 21 ++ src/app/[locale]/layout.tsx | 8 +- src/app/[locale]/page.tsx | 1 + src/constants.ts | 2 + 9 files changed, 419 insertions(+), 326 deletions(-) create mode 100644 src/app/[locale]/(main)/auth/sign-in/SignInClient.tsx create mode 100644 src/app/[locale]/(main)/elo/EloClient.tsx diff --git a/src/app/[locale]/(main)/auth/sign-in/SignInClient.tsx b/src/app/[locale]/(main)/auth/sign-in/SignInClient.tsx new file mode 100644 index 0000000..7245a15 --- /dev/null +++ b/src/app/[locale]/(main)/auth/sign-in/SignInClient.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { useSearchParams } from "next/navigation"; + +import { SignInForm } from "@/components/SignInForm"; +import { Locale, getPathname, routing } from "@/utils/routing"; + +export default function SignInClient() { + const t = useTranslations("SignIn"); + const params = useSearchParams(); + const locale = useLocale(); + + const allowedLocales = routing.locales as unknown as Array; + const effectiveLocal = allowedLocales.includes(locale) + ? (locale as Locale) + : routing.defaultLocale; + + const fallbackPath = getPathname({ + locale: effectiveLocal, + href: { pathname: "/tournaments" }, + }); + + const fallbackUrl = `/${effectiveLocal}${fallbackPath}`; + + return ( +
+
+

+ {t("title")} +

+

+ {t("info")} +

+ + +
+
+ ); +} diff --git a/src/app/[locale]/(main)/auth/sign-in/page.tsx b/src/app/[locale]/(main)/auth/sign-in/page.tsx index 518937a..7332604 100644 --- a/src/app/[locale]/(main)/auth/sign-in/page.tsx +++ b/src/app/[locale]/(main)/auth/sign-in/page.tsx @@ -1,43 +1,27 @@ -"use client"; +import { Metadata } from "next"; -import { useLocale, useTranslations } from "next-intl"; -import { useSearchParams } from "next/navigation"; +import SignInClient from "@/app/[locale]/(main)/auth/sign-in/SignInClient"; +import { baseUrl } from "@/constants"; -import { SignInForm } from "@/components/SignInForm"; -import { Locale, getPathname, routing } from "@/utils/routing"; +export async function generateMetadata({ + params: { locale }, +}: { + params: { locale?: string }; +}): Promise { + return { + alternates: { + canonical: + locale === "fr" + ? `${baseUrl}/auth/sign-in` + : `${baseUrl}/${locale}/auth/sign-in`, + languages: { + fr: `${baseUrl}/auth/sign-in`, + en: `${baseUrl}/en/auth/sign-in`, + }, + }, + }; +} export default function SignIn() { - const t = useTranslations("SignIn"); - const params = useSearchParams(); - const locale = useLocale(); - - const allowedLocales = routing.locales as unknown as Array; - const effectiveLocal = allowedLocales.includes(locale) - ? (locale as Locale) - : routing.defaultLocale; - - const fallbackPath = getPathname({ - locale: effectiveLocal, - href: { pathname: "/tournaments" }, - }); - - const fallbackUrl = `/${effectiveLocal}${fallbackPath}`; - - return ( -
-
-

- {t("title")} -

-

- {t("info")} -

- - -
-
- ); + return ; } diff --git a/src/app/[locale]/(main)/clubs/page.tsx b/src/app/[locale]/(main)/clubs/page.tsx index cba6a83..b1ad144 100644 --- a/src/app/[locale]/(main)/clubs/page.tsx +++ b/src/app/[locale]/(main)/clubs/page.tsx @@ -1,5 +1,7 @@ +import { Metadata } from "next"; import { unstable_cache } from "next/cache"; +import { baseUrl } from "@/constants"; import { collections, dbConnect } from "@/server/mongodb"; import { Club } from "@/types"; import { filterClubsByManualEntry } from "@/utils/clubFilters"; @@ -9,6 +11,23 @@ import ClubsDisplay from "./ClubsDisplay"; export const revalidate = 3600; // Revalidate cache every 6 hours +export async function generateMetadata({ + params: { locale }, +}: { + params: { locale?: string }; +}): Promise { + return { + alternates: { + canonical: + locale === "fr" ? `${baseUrl}/clubs` : `${baseUrl}/${locale}/clubs`, + languages: { + fr: `${baseUrl}/clubs`, + en: `${baseUrl}/en/clubs`, + }, + }, + }; +} + const getClubs = async () => { try { await dbConnect(); diff --git a/src/app/[locale]/(main)/elo/EloClient.tsx b/src/app/[locale]/(main)/elo/EloClient.tsx new file mode 100644 index 0000000..c7827b4 --- /dev/null +++ b/src/app/[locale]/(main)/elo/EloClient.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { isEmpty, sortBy } from "lodash"; +import { useTranslations } from "next-intl"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@/components/Button"; +import { ErrorBox } from "@/components/ErrorBox"; +import { Spinner } from "@/components/Spinner"; +import { TranslatedError } from "@/components/TranslatedError"; +import { SelectField } from "@/components/form/SelectField"; +import { TournamentSelectField } from "@/components/form/TournamentSelectField"; +import { fetchTournamentResultsSchema } from "@/schemas"; +import { fetchTournamentResults } from "@/server/fetchTournamentResults"; +import { SearchedTournament } from "@/server/searchTournaments"; + +// import { TimeControl } from "@/types"; +// import { Link } from "@/utils/routing"; +import { KFactor } from "./KFactor"; +import { ManualEloForm } from "./ManualEloForm"; +import { TournamentResults } from "./TournamentResults"; + +const kFactors = ["40", "20", "10"]; + +const formSchema = fetchTournamentResultsSchema.extend({ + tournamentId: z.string().optional(), + player: z.string().optional(), + kFactor: z.string(), +}); + +type FetchResultsFormValues = z.infer; + +export default function EloClient() { + const t = useTranslations("Elo"); + // type TranslationKey = Parameters[0]; + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const current = useMemo( + () => new URLSearchParams(Array.from(searchParams.entries())), + [searchParams], + ); + const [tournament, setTournament] = useState(null); + + const tournamentId = searchParams.get("tId") ?? ""; + const kFactorParam = searchParams.get("k"); + const player = searchParams.get("player") ?? ""; + + const kFactor = + kFactorParam && kFactors.includes(kFactorParam) ? kFactorParam : "20"; + + const hasTournamentId = !isEmpty(tournamentId.trim()); + + const { + data: allResults, + isFetching, + error, + } = useQuery({ + queryKey: ["fetchTournamentResults", { id: tournamentId?.trim() }], + queryFn: async () => fetchTournamentResults({ id: tournamentId?.trim() }), + refetchOnWindowFocus: false, + staleTime: Infinity, + gcTime: 10 * 60 * 1000, + enabled: hasTournamentId, + retry: false, + }); + + const playerOptions = sortBy( + (allResults?.data ?? []).map((player) => ({ + value: player.id, + label: player.name, + })), + "label", + ); + + const form = useForm({ + resolver: zodResolver(fetchTournamentResultsSchema), + defaultValues: { + tournamentId: tournamentId ?? "", + kFactor: kFactor ?? "20", + player: player ?? "", + }, + }); + + const clearForm = useCallback(() => { + current.delete("tId"); + current.delete("k"); + current.delete("player"); + + form.setValue("tournamentId", ""); + form.setValue("kFactor", "20"); + form.setValue("player", ""); + + const search = current.toString(); + const query = search ? `?${search}` : ""; + + router.push(`${pathname}${query}`); + }, [current, form, pathname, router]); + + useEffect(() => { + // We subscribe to form changes and update the URL parameters + const subscription = form.watch((value, { name, type }) => { + let update = false; + if (type === "change") { + if ( + name === "tournamentId" && + value.tournamentId !== tournamentId && + value.tournamentId + ) { + current.set("tId", value.tournamentId); + update = true; + } else if ( + name === "tournamentId" && + value.tournamentId !== tournamentId && + value.tournamentId === null + ) { + clearForm(); + } else if ( + name === "player" && + value.player !== player && + value.player + ) { + current.set("player", value.player); + update = true; + } else if ( + name === "kFactor" && + value.kFactor !== kFactor && + value.kFactor + ) { + current.set("k", value.kFactor); + update = true; + } + + if (update) { + const search = current.toString(); + const query = search ? `?${search}` : ""; + router.replace(`${pathname}${query}`); + } + } + }); + return () => subscription.unsubscribe(); + }, [ + clearForm, + current, + form, + form.watch, + kFactor, + pathname, + player, + router, + tournamentId, + ]); + + useEffect(() => { + // When the URL changes, we update the form values + if (tournamentId !== form.getValues("tournamentId")) { + form.setValue("tournamentId", tournamentId); + } + + if (player !== form.getValues("player")) { + form.setValue("player", player); + } + + if (kFactor !== form.getValues("kFactor")) { + form.setValue("kFactor", form.getValues("kFactor")); + } + }, [searchParams, form, player, kFactor, tournamentId]); + + const playerResults = allResults?.data?.find((p) => p.id === player); + + return ( +
+
+

+ {t("title")} +

+

+ {t("info")} +

+ + {hasTournamentId && !isFetching && !error && ( +
+ +
+ )} + + +
+ t("noTournamentsFound")} + onInformChange={(tournaments) => { + setTournament(tournaments?.[0]?.data ?? null); + }} + /> + + {isFetching && ( +
+ +
+ )} + + {!isFetching && playerOptions.length > 0 && ( +
+
+ + + ({ + value: k, + label: k, + }))} + isClearable={false} + required + /> +
+ + +
+ )} + +
+ + {hasTournamentId && + !isFetching && + player && + playerResults && + !error && ( + + )} + + {(!!error || allResults?.serverError) && ( + } + /> + )} + + {((!hasTournamentId && !isFetching) || !!error) && ( + <> +
+
+
+ {t("useManualLabel")} +
+
+ + + + )} +
+
+ ); +} diff --git a/src/app/[locale]/(main)/elo/page.tsx b/src/app/[locale]/(main)/elo/page.tsx index cda02c1..c77159a 100644 --- a/src/app/[locale]/(main)/elo/page.tsx +++ b/src/app/[locale]/(main)/elo/page.tsx @@ -1,288 +1,25 @@ -"use client"; +import { Metadata } from "next"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import EloClient from "@/app/[locale]/(main)/elo/EloClient"; +import { baseUrl } from "@/constants"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useQuery } from "@tanstack/react-query"; -import { isEmpty, sortBy } from "lodash"; -import { useTranslations } from "next-intl"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { FormProvider, useForm } from "react-hook-form"; -import { z } from "zod"; - -import { Button } from "@/components/Button"; -import { ErrorBox } from "@/components/ErrorBox"; -import { Spinner } from "@/components/Spinner"; -import { TranslatedError } from "@/components/TranslatedError"; -import { SelectField } from "@/components/form/SelectField"; -import { TournamentSelectField } from "@/components/form/TournamentSelectField"; -import { fetchTournamentResultsSchema } from "@/schemas"; -import { fetchTournamentResults } from "@/server/fetchTournamentResults"; -import { SearchedTournament } from "@/server/searchTournaments"; -import { TimeControl } from "@/types"; -import { Link } from "@/utils/routing"; - -import { KFactor } from "./KFactor"; -import { ManualEloForm } from "./ManualEloForm"; -import { TournamentResults } from "./TournamentResults"; - -const kFactors = ["40", "20", "10"]; - -const formSchema = fetchTournamentResultsSchema.extend({ - tournamentId: z.string().optional(), - player: z.string().optional(), - kFactor: z.string(), -}); - -type FetchResultsFormValues = z.infer; +export async function generateMetadata({ + params: { locale }, +}: { + params: { locale?: string }; +}): Promise { + return { + alternates: { + canonical: + locale === "fr" ? `${baseUrl}/elo` : `${baseUrl}/${locale}/elo`, + languages: { + fr: `${baseUrl}/elo`, + en: `${baseUrl}/en/elo`, + }, + }, + }; +} export default function Elo() { - const t = useTranslations("Elo"); - type TranslationKey = Parameters[0]; - - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const current = useMemo( - () => new URLSearchParams(Array.from(searchParams.entries())), - [searchParams], - ); - const [tournament, setTournament] = useState(null); - - const tournamentId = searchParams.get("tId") ?? ""; - const kFactorParam = searchParams.get("k"); - const player = searchParams.get("player") ?? ""; - - const kFactor = - kFactorParam && kFactors.includes(kFactorParam) ? kFactorParam : "20"; - - const hasTournamentId = !isEmpty(tournamentId.trim()); - - const { - data: allResults, - isFetching, - error, - } = useQuery({ - queryKey: ["fetchTournamentResults", { id: tournamentId?.trim() }], - queryFn: async () => fetchTournamentResults({ id: tournamentId?.trim() }), - refetchOnWindowFocus: false, - staleTime: Infinity, - gcTime: 10 * 60 * 1000, - enabled: hasTournamentId, - retry: false, - }); - - const playerOptions = sortBy( - (allResults?.data ?? []).map((player) => ({ - value: player.id, - label: player.name, - })), - "label", - ); - - const form = useForm({ - resolver: zodResolver(fetchTournamentResultsSchema), - defaultValues: { - tournamentId: tournamentId ?? "", - kFactor: kFactor ?? "20", - player: player ?? "", - }, - }); - - const clearForm = useCallback(() => { - current.delete("tId"); - current.delete("k"); - current.delete("player"); - - form.setValue("tournamentId", ""); - form.setValue("kFactor", "20"); - form.setValue("player", ""); - - const search = current.toString(); - const query = search ? `?${search}` : ""; - - router.push(`${pathname}${query}`); - }, [current, form, pathname, router]); - - useEffect(() => { - // We subscribe to form changes and update the URL parameters - const subscription = form.watch((value, { name, type }) => { - let update = false; - if (type === "change") { - if ( - name === "tournamentId" && - value.tournamentId !== tournamentId && - value.tournamentId - ) { - current.set("tId", value.tournamentId); - update = true; - } else if ( - name === "tournamentId" && - value.tournamentId !== tournamentId && - value.tournamentId === null - ) { - clearForm(); - } else if ( - name === "player" && - value.player !== player && - value.player - ) { - current.set("player", value.player); - update = true; - } else if ( - name === "kFactor" && - value.kFactor !== kFactor && - value.kFactor - ) { - current.set("k", value.kFactor); - update = true; - } - - if (update) { - const search = current.toString(); - const query = search ? `?${search}` : ""; - router.replace(`${pathname}${query}`); - } - } - }); - return () => subscription.unsubscribe(); - }, [ - clearForm, - current, - form, - form.watch, - kFactor, - pathname, - player, - router, - tournamentId, - ]); - - useEffect(() => { - // When the URL changes, we update the form values - if (tournamentId !== form.getValues("tournamentId")) { - form.setValue("tournamentId", tournamentId); - } - - if (player !== form.getValues("player")) { - form.setValue("player", player); - } - - if (kFactor !== form.getValues("kFactor")) { - form.setValue("kFactor", form.getValues("kFactor")); - } - }, [searchParams, form, player, kFactor, tournamentId]); - - const playerResults = allResults?.data?.find((p) => p.id === player); - - return ( -
-
-

- {t("title")} -

-

- {t("info")} -

- - {hasTournamentId && !isFetching && !error && ( -
- -
- )} - - -
- t("noTournamentsFound")} - onInformChange={(tournaments) => { - setTournament(tournaments?.[0]?.data ?? null); - }} - /> - - {isFetching && ( -
- -
- )} - - {!isFetching && playerOptions.length > 0 && ( -
-
- - - ({ - value: k, - label: k, - }))} - isClearable={false} - required - /> -
- - -
- )} - -
- - {hasTournamentId && - !isFetching && - player && - playerResults && - !error && ( - - )} - - {(!!error || allResults?.serverError) && ( - } - /> - )} - - {((!hasTournamentId && !isFetching) || !!error) && ( - <> -
-
-
- {t("useManualLabel")} -
-
- - - - )} -
-
- ); + return ; } diff --git a/src/app/[locale]/(main)/tournaments/page.tsx b/src/app/[locale]/(main)/tournaments/page.tsx index 63bd9de..1776124 100644 --- a/src/app/[locale]/(main)/tournaments/page.tsx +++ b/src/app/[locale]/(main)/tournaments/page.tsx @@ -6,8 +6,10 @@ import { } from "date-fns"; import { fr } from "date-fns/locale"; import { groupBy } from "lodash"; +import { Metadata } from "next"; import { unstable_cache } from "next/cache"; +import { baseUrl } from "@/constants"; import { tournamentModelSchema } from "@/server/models/tournamentModel"; import { collections, dbConnect } from "@/server/mongodb"; import { TimeControl, Tournament, tcMap } from "@/types"; @@ -17,6 +19,25 @@ import TournamentsDisplay from "./TournamentsDisplay"; setDefaultOptions({ locale: fr }); +export async function generateMetadata({ + params: { locale }, +}: { + params: { locale?: string }; +}): Promise { + return { + alternates: { + canonical: + locale === "fr" + ? `${baseUrl}/tournois` + : `${baseUrl}/${locale}/tournaments`, + languages: { + fr: `${baseUrl}/tournois`, + en: `${baseUrl}/en/tournaments`, + }, + }, + }; +} + const getTournaments = async () => { try { await dbConnect(); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 40d1629..b4ba88c 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -7,6 +7,7 @@ import { notFound } from "next/navigation"; import { LocaleChecker } from "@/components/LocaleChecker"; import { MatomoAnalytics } from "@/components/MatomoAnalytics"; +import { baseUrl } from "@/constants"; import "@/css/globals.css"; import Providers from "@/providers"; @@ -31,15 +32,12 @@ export async function generateMetadata({ namespace: "Metadata", }); - const baseUrl = "https://echecsfrance.com"; - const canonicalPath = locale === "fr" ? "" : `/${locale}`; - return { title: t("title"), description: t("description"), keywords: t("keywords"), alternates: { - canonical: `${baseUrl}${canonicalPath}`, + canonical: locale === "fr" ? baseUrl : `${baseUrl}/${locale}`, languages: { fr: baseUrl, en: `${baseUrl}/en`, @@ -67,7 +65,7 @@ export default async function RootLayout({ diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 89ed88a..ed42aab 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -18,6 +18,7 @@ export default function Home() { src={bannerImage} alt="banner" sizes="100vw" + priority={true} style={{ width: "100%", height: "auto", diff --git a/src/constants.ts b/src/constants.ts index 64e8189..38529c1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,3 +6,5 @@ export const TimeControlColours = { [TimeControl.Blitz]: "#ddce20", [TimeControl.Other]: "#ea5f17", }; + +export const baseUrl = "https://echecsfrance.com";