From 8cc562e1c5159ecda9b377bd6d61ac6e70058456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Martin?= Date: Mon, 2 Mar 2026 10:29:55 +0100 Subject: [PATCH 1/4] Fix setup localization and language selection --- server.ts | 3 +- src/client/locales/en.ts | 32 +++++ src/client/locales/fr.ts | 35 ++++++ src/routes/setup.tsx | 114 ++++++++++++------ src/routes/setup/-components/admin-step.tsx | 70 +++++++---- src/routes/setup/-components/index.ts | 15 ++- .../setup/-components/language-step.tsx | 96 +++++++++++++++ .../setup/-components/safe-search-step.tsx | 71 ++++++----- src/routes/setup/-components/setup-form.ts | 13 ++ 9 files changed, 350 insertions(+), 99 deletions(-) create mode 100644 src/routes/setup/-components/language-step.tsx create mode 100644 src/routes/setup/-components/setup-form.ts diff --git a/server.ts b/server.ts index e612c10..7c6287f 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,3 @@ -import type { ServeOptions, Server } from 'bun' import path from 'node:path' const SERVER_PORT = Number(process.env.PORT ?? 3000) @@ -254,7 +253,7 @@ async function initializeServer() { log.error(`Uncaught server error: ${error.message}`) return new Response('Internal Server Error', { status: 500 }) }, - } as unknown as ServeOptions) as Server + }) log.success(`Server listening on http://localhost:${String(server.port)}`) } diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index ec72b30..4018a86 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -11,6 +11,7 @@ export const en = { saveChanges: "Save changes", savedSuccess: "Settings saved successfully", savedError: "Failed to save settings", + genericError: "Something went wrong", }, search: { placeholder: "What are you looking for?", @@ -113,5 +114,36 @@ export const en = { en: "English", fr: "Français", }, + setup: { + title: "Initial Configuration", + steps: { + language: "Language", + languageDescription: "Choose your preferred language", + safeSearch: "Content Filtering", + safeSearchDescription: "Configure content filtering", + admin: "Admin Account", + adminDescription: "Create your administrator account", + }, + language: { + title: "Language", + description: "Please select your preferred language for the interface.", + continue: "Continue", + }, + admin: { + name: "Name", + email: "Email", + password: "Password", + confirmPassword: "Confirm Password", + createAccount: "Create Account", + nameRequired: "Name is required", + emailInvalid: "Invalid email", + passwordMinLength: "Password must be at least 8 characters", + passwordsDoNotMatch: "Passwords do not match", + }, + safeSearch: { + title: "Safe Search", + description: "Select the level of content filtering you prefer.", + }, + }, }, }; diff --git a/src/client/locales/fr.ts b/src/client/locales/fr.ts index 6a20139..727cff5 100644 --- a/src/client/locales/fr.ts +++ b/src/client/locales/fr.ts @@ -11,6 +11,7 @@ export const fr = { saveChanges: "Enregistrer les modifications", savedSuccess: "Paramètres enregistrés avec succès", savedError: "Échec de l'enregistrement des paramètres", + genericError: "Une erreur est survenue", }, search: { placeholder: "Que recherchez-vous ?", @@ -114,5 +115,39 @@ export const fr = { en: "English", fr: "Français", }, + setup: { + title: "Configuration initiale", + steps: { + language: "Langue", + languageDescription: "Choisissez votre langue préférée", + safeSearch: "Filtrage", + safeSearchDescription: "Configurez le filtrage de contenu", + admin: "Compte Admin", + adminDescription: "Créez votre compte administrateur", + }, + language: { + title: "Langue", + description: + "Veuillez sélectionner votre langue préférée pour l'interface.", + continue: "Continuer", + }, + admin: { + name: "Nom", + email: "Email", + password: "Mot de passe", + confirmPassword: "Confirmer le mot de passe", + createAccount: "Créer le compte", + nameRequired: "Le nom est requis", + emailInvalid: "Email invalide", + passwordMinLength: + "Le mot de passe doit contenir au moins 8 caractères", + passwordsDoNotMatch: "Les mots de passe ne correspondent pas", + }, + safeSearch: { + title: "Safe Search", + description: + "Sélectionnez le niveau de filtrage de contenu que vous préférez.", + }, + }, }, }; diff --git a/src/routes/setup.tsx b/src/routes/setup.tsx index 4ebd52f..1f2d345 100644 --- a/src/routes/setup.tsx +++ b/src/routes/setup.tsx @@ -1,10 +1,10 @@ import { defineStepper } from "@stepperize/react"; -import { useForm } from "@tanstack/react-form"; import { useMutation } from "@tanstack/react-query"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { useServerFn } from "@tanstack/react-start"; import { Check } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { cn } from "@/client/utils"; import { SafeSearch } from "@/server/domain/value-objects"; import { @@ -13,21 +13,30 @@ import { } from "@/server/infrastructure/functions/setup"; import { AdminStep, + adminFormOpts, + createStep2Schema, + LanguageStep, + languageFormOpts, SafeSearchStep, - step2Schema, - stepSafeSearchSchema, + safeSearchFormOpts, } from "./setup/-components"; +import { useSetupForm } from "./setup/-components/setup-form"; const { useStepper } = defineStepper( + { + id: "language", + title: "setup.steps.language", + description: "setup.steps.languageDescription", + }, { id: "safe-search", - title: "Filtrage", - description: "Configurez le filtrage de contenu", + title: "setup.steps.safeSearch", + description: "setup.steps.safeSearchDescription", }, { id: "admin", - title: "Compte Admin", - description: "Créez votre compte administrateur", + title: "setup.steps.admin", + description: "setup.steps.adminDescription", }, ); @@ -48,11 +57,17 @@ export const Route = createFileRoute("/setup")({ }); function SetupPage() { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const stepper = useStepper(); const [error, setError] = useState(null); const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); const [mounted, setMounted] = useState(false); + const normalizedLanguage = ( + i18n.resolvedLanguage || + i18n.language || + "en" + ).split("-")[0] as "en" | "fr"; useEffect(() => { setMounted(true); @@ -71,32 +86,36 @@ function SetupPage() { }, onError: (error) => { setError( - error instanceof Error ? error.message : "Une erreur est survenue", + error instanceof Error ? error.message : t("common.genericError"), ); }, }); - const safeSearchForm = useForm({ + const languageForm = useSetupForm({ + ...languageFormOpts, defaultValues: { - safeSearch: SafeSearch.MODERATE as SafeSearch, - }, - validators: { - onChange: stepSafeSearchSchema, + ...languageFormOpts.defaultValues, + language: normalizedLanguage, }, onSubmit: async () => { stepper.navigation.next(); }, }); - const adminForm = useForm({ - defaultValues: { - name: "", - email: "", - password: "", - confirmPassword: "", + const safeSearchForm = useSetupForm({ + ...safeSearchFormOpts, + onSubmit: async () => { + stepper.navigation.next(); }, + }); + + const adminSchema = useMemo(() => createStep2Schema(t), [t]); + + const adminForm = useSetupForm({ + ...adminFormOpts, validators: { - onChange: step2Schema, + ...adminFormOpts.validators, + onChange: adminSchema, }, onSubmit: async ({ value }) => { const rawSafeSearch = safeSearchForm.state.values.safeSearch; @@ -127,7 +146,7 @@ function SetupPage() {

- Configuration initiale + {t("setup.title")}

@@ -178,7 +197,7 @@ function SetupPage() { : "text-muted-foreground", )} > - {step.title} + {t(step.title)}
@@ -199,32 +218,47 @@ function SetupPage() {

- {mounted - ? stepper.state.current.data.title - : stepper.state.all[0].title} + {t( + mounted + ? stepper.state.current.data.title + : stepper.state.all[0].title, + )} :{" "} - {mounted - ? stepper.state.current.data.description - : stepper.state.all[0].description} + {t( + mounted + ? stepper.state.current.data.description + : stepper.state.all[0].description, + )}

{stepper.flow.switch({ + language: () => ( + + languageForm.handleSubmit()} /> + + ), "safe-search": () => ( - safeSearchForm.handleSubmit()} - /> + + safeSearchForm.handleSubmit()} + onBack={() => stepper.navigation.prev()} + /> + ), admin: () => ( - stepper.navigation.prev()} - onSubmit={() => setHasAttemptedSubmit(true)} - /> + + stepper.navigation.prev()} + onSubmit={() => { + setHasAttemptedSubmit(true); + adminForm.handleSubmit(); + }} + /> + ), })} diff --git a/src/routes/setup/-components/admin-step.tsx b/src/routes/setup/-components/admin-step.tsx index c0c0fa1..29c26e7 100644 --- a/src/routes/setup/-components/admin-step.tsx +++ b/src/routes/setup/-components/admin-step.tsx @@ -1,28 +1,52 @@ +import { formOptions } from "@tanstack/react-form"; import { useId } from "react"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { Button } from "@/client/components/ui/button"; import { Input } from "@/client/components/ui/input"; import { Label } from "@/client/components/ui/label"; +import { useSetupTypedFormContext } from "./setup-form"; +export const createStep2Schema = (t: (key: string) => string) => + z + .object({ + name: z.string().min(1, t("setup.admin.nameRequired")), + email: z.string().email(t("setup.admin.emailInvalid")), + password: z.string().min(8, t("setup.admin.passwordMinLength")), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: t("setup.admin.passwordsDoNotMatch"), + path: ["confirmPassword"], + }); + +// Fallback schema for types or initial render export const step2Schema = z .object({ - name: z.string().min(1, "Le nom est requis"), - email: z.email("Email invalide"), - password: z - .string() - .min(8, "Le mot de passe doit contenir au moins 8 caractères"), + name: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { - message: "Les mots de passe ne correspondent pas", path: ["confirmPassword"], }); export type AdminFormValues = z.infer; +export const adminFormOpts = formOptions({ + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + }, + validators: { + onChange: step2Schema, + }, +}); + export interface AdminStepProps { - // biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex - form: any; onBack: () => void; onSubmit?: () => void; isPending: boolean; @@ -30,12 +54,13 @@ export interface AdminStepProps { } export function AdminStep({ - form, onBack, onSubmit, isPending, hasAttemptedSubmit, }: AdminStepProps) { + const form = useSetupTypedFormContext(adminFormOpts); + const { t } = useTranslation(); const nameId = useId(); const emailId = useId(); const passwordId = useId(); @@ -46,15 +71,13 @@ export function AdminStep({ onSubmit={(e) => { e.preventDefault(); onSubmit?.(); - form.handleSubmit(); }} className="space-y-4" >
- + - {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( <>
- + - {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( <>
- + - {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( <>
- + - {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( <> - Retour + {t("common.back")}
diff --git a/src/routes/setup/-components/index.ts b/src/routes/setup/-components/index.ts index ea92331..895eb56 100644 --- a/src/routes/setup/-components/index.ts +++ b/src/routes/setup/-components/index.ts @@ -1,6 +1,19 @@ -export { type AdminFormValues, AdminStep, step2Schema } from "./admin-step"; +export { + type AdminFormValues, + AdminStep, + adminFormOpts, + createStep2Schema, + step2Schema, +} from "./admin-step"; +export { + LanguageStep, + type LanguageStepFormValues, + languageFormOpts, + stepLanguageSchema, +} from "./language-step"; export { type SafeSearchFormValues, SafeSearchStep, + safeSearchFormOpts, stepSafeSearchSchema, } from "./safe-search-step"; diff --git a/src/routes/setup/-components/language-step.tsx b/src/routes/setup/-components/language-step.tsx new file mode 100644 index 0000000..c7ca53c --- /dev/null +++ b/src/routes/setup/-components/language-step.tsx @@ -0,0 +1,96 @@ +import { formOptions } from "@tanstack/react-form"; +import { Languages } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { Button } from "@/client/components/ui/button"; +import { Label } from "@/client/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/client/components/ui/radio-group"; +import { useSetupTypedFormContext } from "./setup-form"; + +export const stepLanguageSchema = z.object({ + language: z.enum(["en", "fr"]), +}); + +export type LanguageStepFormValues = z.infer; + +export const languageFormOpts = formOptions({ + defaultValues: { + language: "en" as LanguageStepFormValues["language"], + }, + validators: { + onChange: stepLanguageSchema, + }, +}); + +export interface LanguageStepProps { + onSubmit: () => void; +} + +export function LanguageStep({ onSubmit }: LanguageStepProps) { + const form = useSetupTypedFormContext(languageFormOpts); + const { t, i18n } = useTranslation(); + + const languageOptions = [ + { + value: "en", + label: t("languages.en"), + }, + { + value: "fr", + label: t("languages.fr"), + }, + ]; + + const handleLanguageChange = (value: string) => { + i18n.changeLanguage(value); + }; + + return ( +
{ + e.preventDefault(); + onSubmit(); + }} + className="space-y-6" + > +
+ + {(field) => ( + { + field.handleChange(value as LanguageStepFormValues["language"]); + handleLanguageChange(value); + }} + className="gap-3" + > + {languageOptions.map((option) => ( + + ))} + + )} + +
+ + +
+ ); +} diff --git a/src/routes/setup/-components/safe-search-step.tsx b/src/routes/setup/-components/safe-search-step.tsx index 77a55ca..1d609f0 100644 --- a/src/routes/setup/-components/safe-search-step.tsx +++ b/src/routes/setup/-components/safe-search-step.tsx @@ -1,9 +1,12 @@ +import { formOptions } from "@tanstack/react-form"; import { Shield, ShieldAlert, ShieldOff } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { Button } from "@/client/components/ui/button"; import { Label } from "@/client/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/client/components/ui/radio-group"; import { SafeSearch } from "@/server/domain/value-objects"; +import { useSetupTypedFormContext } from "./setup-form"; export const stepSafeSearchSchema = z.object({ safeSearch: z.enum([SafeSearch.OFF, SafeSearch.MODERATE, SafeSearch.STRICT]), @@ -11,40 +14,45 @@ export const stepSafeSearchSchema = z.object({ export type SafeSearchFormValues = z.infer; +export const safeSearchFormOpts = formOptions({ + defaultValues: { + safeSearch: SafeSearch.MODERATE as SafeSearchFormValues["safeSearch"], + }, + validators: { + onChange: stepSafeSearchSchema, + }, +}); + export interface SafeSearchStepProps { - // biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex - form: any; onSubmit: () => void; onBack?: () => void; } -const safeSearchOptions = [ - { - value: SafeSearch.OFF, - label: "Désactivé", - description: "Aucun filtrage - tous les résultats sont affichés", - icon: ShieldOff, - }, - { - value: SafeSearch.MODERATE, - label: "Modéré", - description: "Filtre le contenu explicite le plus sensible", - icon: Shield, - }, - { - value: SafeSearch.STRICT, - label: "Strict", - description: - "Filtre tout le contenu explicite (recommandé pour les familles)", - icon: ShieldAlert, - }, -]; +export function SafeSearchStep({ onSubmit, onBack }: SafeSearchStepProps) { + const form = useSetupTypedFormContext(safeSearchFormOpts); + const { t } = useTranslation(); + + const safeSearchOptions = [ + { + value: SafeSearch.OFF, + label: t("safeSearch.off"), + description: t("safeSearch.offDescription"), + icon: ShieldOff, + }, + { + value: SafeSearch.MODERATE, + label: t("safeSearch.moderate"), + description: t("safeSearch.moderateDescription"), + icon: Shield, + }, + { + value: SafeSearch.STRICT, + label: t("safeSearch.strict"), + description: t("safeSearch.strictDescription"), + icon: ShieldAlert, + }, + ]; -export function SafeSearchStep({ - form, - onSubmit, - onBack, -}: SafeSearchStepProps) { return (
{ @@ -55,8 +63,7 @@ export function SafeSearchStep({ >
- {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( field.handleChange(value as SafeSearch)} @@ -100,11 +107,11 @@ export function SafeSearchStep({ onClick={onBack} className="flex-1" > - Retour + {t("common.back")} )}
diff --git a/src/routes/setup/-components/setup-form.ts b/src/routes/setup/-components/setup-form.ts new file mode 100644 index 0000000..b8a30e0 --- /dev/null +++ b/src/routes/setup/-components/setup-form.ts @@ -0,0 +1,13 @@ +import { createFormHook, createFormHookContexts } from "@tanstack/react-form"; + +const { fieldContext, formContext } = createFormHookContexts(); + +export const { + useAppForm: useSetupForm, + useTypedAppFormContext: useSetupTypedFormContext, +} = createFormHook({ + fieldComponents: {}, + formComponents: {}, + fieldContext, + formContext, +}); From 769f841bcac4d97f62976ad3ffb8791191f98009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Martin?= Date: Mon, 2 Mar 2026 14:26:56 +0100 Subject: [PATCH 2/4] Enhance localization and validation in setup process - Introduce default language handling and supported language codes in i18n configuration. - Add validation messages for required fields in English and French locales. - Refactor language handling in setup and settings components to utilize normalized language codes. - Update admin step schema to improve password confirmation validation. - Streamline language selection in the language switcher and language step components. --- src/client/i18n.ts | 8 ++- src/client/languages.ts | 28 ++++++++++ src/client/locales/en.ts | 6 ++ src/client/locales/fr.ts | 6 ++ src/client/zod.ts | 13 +++++ src/routes/__root.tsx | 1 + .../-components/settings/general-section.tsx | 3 +- .../settings/language-switcher.tsx | 22 +++++--- src/routes/_authed/settings.tsx | 9 ++- src/routes/setup.tsx | 28 +++++----- src/routes/setup/-components/admin-step.tsx | 37 ++++++------ src/routes/setup/-components/index.ts | 3 +- .../setup/-components/language-step.tsx | 32 +++++------ .../domain/value-objects/settings.vo.ts | 3 +- src/server/infrastructure/functions/setup.ts | 56 ++++++++++++++++--- 15 files changed, 172 insertions(+), 83 deletions(-) create mode 100644 src/client/languages.ts create mode 100644 src/client/zod.ts diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 5846d52..67b6549 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -1,6 +1,7 @@ import i18n from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; +import { defaultLanguageCode, supportedLanguageCodes } from "./languages"; import { en } from "./locales/en"; import { fr } from "./locales/fr"; @@ -12,9 +13,10 @@ i18n en, fr, }, - lng: typeof window === "undefined" ? "en" : undefined, // Force English on server - fallbackLng: "en", - supportedLngs: ["en", "fr"], + lng: + typeof window === "undefined" ? defaultLanguageCode : undefined, // Force English on server + fallbackLng: defaultLanguageCode, + supportedLngs: supportedLanguageCodes, load: "languageOnly", interpolation: { escapeValue: false, // react already safes from xss diff --git a/src/client/languages.ts b/src/client/languages.ts new file mode 100644 index 0000000..ec42c04 --- /dev/null +++ b/src/client/languages.ts @@ -0,0 +1,28 @@ +export const languageOptions = [ + { code: "en", labelKey: "languages.en" }, + { code: "fr", labelKey: "languages.fr" }, +] as const; + +export type LanguageCode = (typeof languageOptions)[number]["code"]; + +export const languageCodeTuple = languageOptions.map( + (option) => option.code, +) as [LanguageCode, ...LanguageCode[]]; + +export const supportedLanguageCodes = languageOptions.map( + (option) => option.code, +); + +export const defaultLanguageCode: LanguageCode = "en"; + +export const normalizeLanguageCode = ( + language: string | null | undefined, +): LanguageCode => { + const normalizedLanguage = (language ?? defaultLanguageCode).split("-")[0]; + + if (supportedLanguageCodes.includes(normalizedLanguage as LanguageCode)) { + return normalizedLanguage as LanguageCode; + } + + return defaultLanguageCode; +}; diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index 4018a86..4adb2bd 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -145,5 +145,11 @@ export const en = { description: "Select the level of content filtering you prefer.", }, }, + validation: { + required: "This field is required", + invalidEmail: "Invalid email", + minLength: "Must be at least {{count}} characters", + invalid: "Invalid value", + }, }, }; diff --git a/src/client/locales/fr.ts b/src/client/locales/fr.ts index 727cff5..2550806 100644 --- a/src/client/locales/fr.ts +++ b/src/client/locales/fr.ts @@ -149,5 +149,11 @@ export const fr = { "Sélectionnez le niveau de filtrage de contenu que vous préférez.", }, }, + validation: { + required: "Ce champ est requis", + invalidEmail: "Email invalide", + minLength: "Doit contenir au moins {{count}} caractères", + invalid: "Valeur invalide", + }, }, }; diff --git a/src/client/zod.ts b/src/client/zod.ts new file mode 100644 index 0000000..7d3cb5c --- /dev/null +++ b/src/client/zod.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import i18n from "./i18n"; +import { normalizeLanguageCode } from "./languages"; + +const applyZodLocale = () => { + const language = normalizeLanguageCode(i18n.resolvedLanguage || i18n.language); + const locale = language === "fr" ? z.locales.fr() : z.locales.en(); + z.config(locale); +}; + +applyZodLocale(); +i18n.on("initialized", applyZodLocale); +i18n.on("languageChanged", applyZodLocale); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 1b08628..85a104b 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -12,6 +12,7 @@ import { getPublicConfig } from "@/server/infrastructure/functions/instance-conf import TanStackQueryDevtools from "../integrations/tanstack-query/devtools"; import appCss from "../styles.css?url"; import "@/client/i18n"; +import "@/client/zod"; import { useTranslation } from "react-i18next"; import { LanguageSync } from "@/client/components/language-sync"; diff --git a/src/routes/_authed/-components/settings/general-section.tsx b/src/routes/_authed/-components/settings/general-section.tsx index c53c158..c8462b7 100644 --- a/src/routes/_authed/-components/settings/general-section.tsx +++ b/src/routes/_authed/-components/settings/general-section.tsx @@ -8,6 +8,7 @@ import { CardHeader, CardTitle, } from "@/client/components/ui/card"; +import type { LanguageCode } from "@/client/languages"; import { LanguageSwitcher } from "@/routes/_authed/-components/settings/language-switcher"; import { useSettingsLayout } from "@/routes/_authed/settings"; @@ -30,7 +31,7 @@ export function GeneralSection() { - handleUserSettingChange("language", val as "en" | "fr") + handleUserSettingChange("language", val as LanguageCode) } /> diff --git a/src/routes/_authed/-components/settings/language-switcher.tsx b/src/routes/_authed/-components/settings/language-switcher.tsx index 8b369d3..8672745 100644 --- a/src/routes/_authed/-components/settings/language-switcher.tsx +++ b/src/routes/_authed/-components/settings/language-switcher.tsx @@ -7,6 +7,10 @@ import { SelectTrigger, SelectValue, } from "@/client/components/ui/select"; +import { + languageOptions, + normalizeLanguageCode, +} from "@/client/languages"; import { authClient } from "@/server/infrastructure/auth/client"; import { getUserSettings, @@ -26,9 +30,10 @@ export function LanguageSwitcher({ const { data: session } = authClient.useSession(); const changeLanguage = async (lng: string) => { + const normalizedLng = normalizeLanguageCode(lng); // Controlled mode: just bubble up the change if (onValueChange) { - onValueChange(lng); + onValueChange(normalizedLng); return; } @@ -37,7 +42,6 @@ export function LanguageSwitcher({ try { // Fetch current settings first to merge const currentSettings = await getUserSettings(); - const normalizedLng = lng.split("-")[0] as "en" | "fr"; // Save to server first await saveUserSettings({ @@ -45,13 +49,13 @@ export function LanguageSwitcher({ }); // Then update client state - i18n.changeLanguage(lng); + i18n.changeLanguage(normalizedLng); } catch (error) { console.error("Failed to save language preference:", error); } } else { // Guest user - just change language - i18n.changeLanguage(lng); + i18n.changeLanguage(normalizedLng); } }; @@ -59,8 +63,7 @@ export function LanguageSwitcher({ // Use resolvedLanguage if available, otherwise language, otherwise 'en' const currentLanguage = value || - (i18n.resolvedLanguage || i18n.language || "en").split("-")[0] || - "en"; + normalizeLanguageCode(i18n.resolvedLanguage || i18n.language); return (
@@ -70,8 +73,11 @@ export function LanguageSwitcher({ - {t("languages.en")} - {t("languages.fr")} + {languageOptions.map((option) => ( + + {t(option.labelKey)} + + ))}
diff --git a/src/routes/_authed/settings.tsx b/src/routes/_authed/settings.tsx index b8a809c..e9af1d6 100644 --- a/src/routes/_authed/settings.tsx +++ b/src/routes/_authed/settings.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/client/components/ui/button"; +import { normalizeLanguageCode } from "@/client/languages"; import { sessionQueryOptions } from "@/routes/_authed"; import { getInstanceConfig, @@ -150,11 +151,9 @@ function SettingsLayout() { }); // Apply language change if needed - const currentLng = ( - i18n.resolvedLanguage || - i18n.language || - "en" - ).split("-")[0]; + const currentLng = normalizeLanguageCode( + i18n.resolvedLanguage || i18n.language, + ); if (userSettings.language !== currentLng) { i18n.changeLanguage(userSettings.language); } diff --git a/src/routes/setup.tsx b/src/routes/setup.tsx index 1f2d345..fe8d615 100644 --- a/src/routes/setup.tsx +++ b/src/routes/setup.tsx @@ -3,8 +3,9 @@ import { useMutation } from "@tanstack/react-query"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { useServerFn } from "@tanstack/react-start"; import { Check } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { normalizeLanguageCode } from "@/client/languages"; import { cn } from "@/client/utils"; import { SafeSearch } from "@/server/domain/value-objects"; import { @@ -14,7 +15,6 @@ import { import { AdminStep, adminFormOpts, - createStep2Schema, LanguageStep, languageFormOpts, SafeSearchStep, @@ -63,11 +63,9 @@ function SetupPage() { const [error, setError] = useState(null); const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); const [mounted, setMounted] = useState(false); - const normalizedLanguage = ( - i18n.resolvedLanguage || - i18n.language || - "en" - ).split("-")[0] as "en" | "fr"; + const normalizedLanguage = normalizeLanguageCode( + i18n.resolvedLanguage || i18n.language, + ); useEffect(() => { setMounted(true); @@ -80,6 +78,7 @@ function SetupPage() { name: string; email: string; password: string; + language: string; }) => finalizeSetupFn({ data }), onSuccess: () => { navigate({ to: "/", replace: true }); @@ -109,14 +108,8 @@ function SetupPage() { }, }); - const adminSchema = useMemo(() => createStep2Schema(t), [t]); - const adminForm = useSetupForm({ ...adminFormOpts, - validators: { - ...adminFormOpts.validators, - onChange: adminSchema, - }, onSubmit: async ({ value }) => { const rawSafeSearch = safeSearchForm.state.values.safeSearch; @@ -133,12 +126,19 @@ function SetupPage() { name: value.name, email: value.email, password: value.password, + language: languageForm.state.values.language, }; setupMutation.mutate(payload); }, }); + const handleAdminBack = () => { + setHasAttemptedSubmit(false); + adminForm.reset(adminForm.state.values, { keepDefaultValues: true }); + stepper.navigation.prev(); + }; + return (
@@ -252,7 +252,7 @@ function SetupPage() { stepper.navigation.prev()} + onBack={handleAdminBack} onSubmit={() => { setHasAttemptedSubmit(true); adminForm.handleSubmit(); diff --git a/src/routes/setup/-components/admin-step.tsx b/src/routes/setup/-components/admin-step.tsx index 29c26e7..1fc2ac1 100644 --- a/src/routes/setup/-components/admin-step.tsx +++ b/src/routes/setup/-components/admin-step.tsx @@ -2,37 +2,32 @@ import { formOptions } from "@tanstack/react-form"; import { useId } from "react"; import { useTranslation } from "react-i18next"; import { z } from "zod"; +import i18n from "@/client/i18n"; import { Button } from "@/client/components/ui/button"; import { Input } from "@/client/components/ui/input"; import { Label } from "@/client/components/ui/label"; import { useSetupTypedFormContext } from "./setup-form"; -export const createStep2Schema = (t: (key: string) => string) => - z - .object({ - name: z.string().min(1, t("setup.admin.nameRequired")), - email: z.string().email(t("setup.admin.emailInvalid")), - password: z.string().min(8, t("setup.admin.passwordMinLength")), - confirmPassword: z.string(), - }) - .refine((data) => data.password === data.confirmPassword, { - message: t("setup.admin.passwordsDoNotMatch"), - path: ["confirmPassword"], - }); - -// Fallback schema for types or initial render -export const step2Schema = z +export const adminSchema = z .object({ - name: z.string().min(1), - email: z.string().email(), - password: z.string().min(8), - confirmPassword: z.string(), + name: z + .string() + .trim() + .min(1, { error: () => i18n.t("setup.admin.nameRequired") }), + email: z.email({ error: () => i18n.t("setup.admin.emailInvalid") }), + password: z + .string() + .min(8, { error: () => i18n.t("setup.admin.passwordMinLength") }), + confirmPassword: z + .string() + .min(1, { error: () => i18n.t("validation.required") }), }) .refine((data) => data.password === data.confirmPassword, { path: ["confirmPassword"], + error: () => i18n.t("setup.admin.passwordsDoNotMatch"), }); -export type AdminFormValues = z.infer; +export type AdminFormValues = z.infer; export const adminFormOpts = formOptions({ defaultValues: { @@ -42,7 +37,7 @@ export const adminFormOpts = formOptions({ confirmPassword: "", }, validators: { - onChange: step2Schema, + onChange: adminSchema, }, }); diff --git a/src/routes/setup/-components/index.ts b/src/routes/setup/-components/index.ts index 895eb56..c5df273 100644 --- a/src/routes/setup/-components/index.ts +++ b/src/routes/setup/-components/index.ts @@ -2,8 +2,7 @@ export { type AdminFormValues, AdminStep, adminFormOpts, - createStep2Schema, - step2Schema, + adminSchema, } from "./admin-step"; export { LanguageStep, diff --git a/src/routes/setup/-components/language-step.tsx b/src/routes/setup/-components/language-step.tsx index c7ca53c..25f3cd4 100644 --- a/src/routes/setup/-components/language-step.tsx +++ b/src/routes/setup/-components/language-step.tsx @@ -5,10 +5,15 @@ import { z } from "zod"; import { Button } from "@/client/components/ui/button"; import { Label } from "@/client/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/client/components/ui/radio-group"; +import { + type LanguageCode, + languageCodeTuple, + languageOptions, +} from "@/client/languages"; import { useSetupTypedFormContext } from "./setup-form"; export const stepLanguageSchema = z.object({ - language: z.enum(["en", "fr"]), + language: z.enum(languageCodeTuple), }); export type LanguageStepFormValues = z.infer; @@ -30,18 +35,7 @@ export function LanguageStep({ onSubmit }: LanguageStepProps) { const form = useSetupTypedFormContext(languageFormOpts); const { t, i18n } = useTranslation(); - const languageOptions = [ - { - value: "en", - label: t("languages.en"), - }, - { - value: "fr", - label: t("languages.fr"), - }, - ]; - - const handleLanguageChange = (value: string) => { + const handleLanguageChange = (value: LanguageCode) => { i18n.changeLanguage(value); }; @@ -60,25 +54,25 @@ export function LanguageStep({ onSubmit }: LanguageStepProps) { value={field.state.value} onValueChange={(value) => { field.handleChange(value as LanguageStepFormValues["language"]); - handleLanguageChange(value); + handleLanguageChange(value as LanguageCode); }} className="gap-3" > {languageOptions.map((option) => ( diff --git a/src/server/domain/value-objects/settings.vo.ts b/src/server/domain/value-objects/settings.vo.ts index d401c38..8e3b3cb 100644 --- a/src/server/domain/value-objects/settings.vo.ts +++ b/src/server/domain/value-objects/settings.vo.ts @@ -1,4 +1,5 @@ import * as z from "zod"; +import { languageCodeTuple } from "@/client/languages"; import { SafeSearch } from "./search.vo"; export const instanceConfigSchema = z.object({}); @@ -13,7 +14,7 @@ export const userSettingsSchema = z.object({ .default(SafeSearch.OFF), openInNewTab: z.boolean().default(true), theme: z.enum(["light", "dark", "system"]).default("system"), - language: z.enum(["en", "fr"]).default("en"), + language: z.enum(languageCodeTuple).default("en"), }); export type UserSettings = z.infer; diff --git a/src/server/infrastructure/functions/setup.ts b/src/server/infrastructure/functions/setup.ts index 29842ad..610c4d1 100644 --- a/src/server/infrastructure/functions/setup.ts +++ b/src/server/infrastructure/functions/setup.ts @@ -1,7 +1,11 @@ import { createServerFn } from "@tanstack/react-start"; -import { zodValidator } from "@tanstack/zod-adapter"; import { eq } from "drizzle-orm"; import { z } from "zod"; +import { + defaultLanguageCode, + languageCodeTuple, + normalizeLanguageCode, +} from "@/client/languages"; export const getSetupStatus = createServerFn({ method: "GET" }).handler( async () => { @@ -27,14 +31,40 @@ export const getSetupStatus = createServerFn({ method: "GET" }).handler( ); const finalizeSetupSchema = z.object({ - name: z.string().min(1, "Le nom est requis"), - email: z.email(), + name: z.string().min(1), + email: z.string().email(), password: z.string().min(8), safeSearch: z.string().optional(), + language: z.enum(languageCodeTuple).optional(), }); +interface FinalizeSetupInput { + name?: unknown; + email?: unknown; + password?: unknown; + safeSearch?: unknown; + language?: unknown; +} + +const normalizeFinalizeSetupInput = (data: unknown): FinalizeSetupInput => { + if (!data || typeof data !== "object") { + return {}; + } + + return data as FinalizeSetupInput; +}; + +const getSetupLocale = (input: FinalizeSetupInput) => { + const language = + typeof input.language === "string" + ? normalizeLanguageCode(input.language) + : defaultLanguageCode; + + return language === "fr" ? z.locales.fr() : z.locales.en(); +}; + export const finalizeSetup = createServerFn({ method: "POST" }) - .inputValidator(zodValidator(finalizeSetupSchema)) + .inputValidator(normalizeFinalizeSetupInput) .handler(async ({ data }) => { const { db } = await import( "@/server/infrastructure/persistence/drizzle/connection" @@ -45,6 +75,14 @@ export const finalizeSetup = createServerFn({ method: "POST" }) const { auth } = await import("@/server/infrastructure/auth"); try { + const locale = getSetupLocale(data); + z.config(locale); + + const validation = finalizeSetupSchema.safeParse(data); + if (!validation.success) { + throw validation.error; + } + const existingAdmin = await db .select() .from(user) @@ -58,17 +96,17 @@ export const finalizeSetup = createServerFn({ method: "POST" }) await auth.api.createUser({ body: { - name: data.name, - email: data.email, - password: data.password, + name: validation.data.name, + email: validation.data.email, + password: validation.data.password, role: "admin", }, }); await auth.api.signInEmail({ body: { - email: data.email, - password: data.password, + email: validation.data.email, + password: validation.data.password, }, }); } catch (error) { From facb1dba60147ccd1e4ee79a1610abcf71d973bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Martin?= Date: Mon, 2 Mar 2026 14:34:36 +0100 Subject: [PATCH 3/4] Refactor language handling and improve setup process - Simplify language configuration in i18n and streamline language selection in components. - Enhance the setup process by adding user ID resolution and default user settings. - Update safe search validation to use enum values for better clarity. --- src/client/i18n.ts | 3 +- src/client/zod.ts | 4 +- .../settings/language-switcher.tsx | 8 +-- src/server/infrastructure/functions/setup.ts | 54 ++++++++++++++++++- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 67b6549..ad88fc7 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -13,8 +13,7 @@ i18n en, fr, }, - lng: - typeof window === "undefined" ? defaultLanguageCode : undefined, // Force English on server + lng: typeof window === "undefined" ? defaultLanguageCode : undefined, // Force English on server fallbackLng: defaultLanguageCode, supportedLngs: supportedLanguageCodes, load: "languageOnly", diff --git a/src/client/zod.ts b/src/client/zod.ts index 7d3cb5c..5e672ee 100644 --- a/src/client/zod.ts +++ b/src/client/zod.ts @@ -3,7 +3,9 @@ import i18n from "./i18n"; import { normalizeLanguageCode } from "./languages"; const applyZodLocale = () => { - const language = normalizeLanguageCode(i18n.resolvedLanguage || i18n.language); + const language = normalizeLanguageCode( + i18n.resolvedLanguage || i18n.language, + ); const locale = language === "fr" ? z.locales.fr() : z.locales.en(); z.config(locale); }; diff --git a/src/routes/_authed/-components/settings/language-switcher.tsx b/src/routes/_authed/-components/settings/language-switcher.tsx index 8672745..0131524 100644 --- a/src/routes/_authed/-components/settings/language-switcher.tsx +++ b/src/routes/_authed/-components/settings/language-switcher.tsx @@ -7,10 +7,7 @@ import { SelectTrigger, SelectValue, } from "@/client/components/ui/select"; -import { - languageOptions, - normalizeLanguageCode, -} from "@/client/languages"; +import { languageOptions, normalizeLanguageCode } from "@/client/languages"; import { authClient } from "@/server/infrastructure/auth/client"; import { getUserSettings, @@ -62,8 +59,7 @@ export function LanguageSwitcher({ // Ensure we have a valid language selection, fallback to 'en' // Use resolvedLanguage if available, otherwise language, otherwise 'en' const currentLanguage = - value || - normalizeLanguageCode(i18n.resolvedLanguage || i18n.language); + value || normalizeLanguageCode(i18n.resolvedLanguage || i18n.language); return (
diff --git a/src/server/infrastructure/functions/setup.ts b/src/server/infrastructure/functions/setup.ts index 610c4d1..f4f2db9 100644 --- a/src/server/infrastructure/functions/setup.ts +++ b/src/server/infrastructure/functions/setup.ts @@ -6,6 +6,8 @@ import { languageCodeTuple, normalizeLanguageCode, } from "@/client/languages"; +import { defaultUserSettings, SafeSearch } from "@/server/domain/value-objects"; +import { getContainer } from "@/server/container"; export const getSetupStatus = createServerFn({ method: "GET" }).handler( async () => { @@ -34,7 +36,9 @@ const finalizeSetupSchema = z.object({ name: z.string().min(1), email: z.string().email(), password: z.string().min(8), - safeSearch: z.string().optional(), + safeSearch: z + .enum([SafeSearch.OFF, SafeSearch.MODERATE, SafeSearch.STRICT]) + .optional(), language: z.enum(languageCodeTuple).optional(), }); @@ -63,6 +67,27 @@ const getSetupLocale = (input: FinalizeSetupInput) => { return language === "fr" ? z.locales.fr() : z.locales.en(); }; +const getCreatedUserId = (createdUser: unknown): string | null => { + if (!createdUser || typeof createdUser !== "object") { + return null; + } + + const data = createdUser as { + user?: { id?: unknown }; + id?: unknown; + }; + + if (typeof data.user?.id === "string" && data.user.id.length > 0) { + return data.user.id; + } + + if (typeof data.id === "string" && data.id.length > 0) { + return data.id; + } + + return null; +}; + export const finalizeSetup = createServerFn({ method: "POST" }) .inputValidator(normalizeFinalizeSetupInput) .handler(async ({ data }) => { @@ -94,7 +119,7 @@ export const finalizeSetup = createServerFn({ method: "POST" }) throw new Error("Setup already completed"); } - await auth.api.createUser({ + const createdUser = await auth.api.createUser({ body: { name: validation.data.name, email: validation.data.email, @@ -103,6 +128,31 @@ export const finalizeSetup = createServerFn({ method: "POST" }) }, }); + let userId = getCreatedUserId(createdUser); + if (!userId) { + const createdAdmin = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, validation.data.email)) + .limit(1); + + userId = createdAdmin[0]?.id ?? null; + } + + if (!userId) { + throw new Error("Unable to resolve created admin user ID"); + } + + const container = await getContainer(); + await container.usecases.saveUserSettings({ + userId, + settings: { + ...defaultUserSettings, + safeSearch: validation.data.safeSearch ?? defaultUserSettings.safeSearch, + language: validation.data.language ?? defaultLanguageCode, + }, + }); + await auth.api.signInEmail({ body: { email: validation.data.email, From 350416bee16c6f5d7bd14d6f1c8db9956d58319e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Martin?= Date: Mon, 2 Mar 2026 14:46:13 +0100 Subject: [PATCH 4/4] Refactor imports and enhance setup functionality - Reorganize imports in admin-step component for clarity. - Integrate default user settings and safe search handling in setup process. - Improve formatting of safe search assignment for better readability. --- src/routes/setup/-components/admin-step.tsx | 2 +- src/server/infrastructure/functions/setup.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/routes/setup/-components/admin-step.tsx b/src/routes/setup/-components/admin-step.tsx index 1fc2ac1..c273d0b 100644 --- a/src/routes/setup/-components/admin-step.tsx +++ b/src/routes/setup/-components/admin-step.tsx @@ -2,10 +2,10 @@ import { formOptions } from "@tanstack/react-form"; import { useId } from "react"; import { useTranslation } from "react-i18next"; import { z } from "zod"; -import i18n from "@/client/i18n"; import { Button } from "@/client/components/ui/button"; import { Input } from "@/client/components/ui/input"; import { Label } from "@/client/components/ui/label"; +import i18n from "@/client/i18n"; import { useSetupTypedFormContext } from "./setup-form"; export const adminSchema = z diff --git a/src/server/infrastructure/functions/setup.ts b/src/server/infrastructure/functions/setup.ts index f4f2db9..5aa938c 100644 --- a/src/server/infrastructure/functions/setup.ts +++ b/src/server/infrastructure/functions/setup.ts @@ -6,8 +6,8 @@ import { languageCodeTuple, normalizeLanguageCode, } from "@/client/languages"; -import { defaultUserSettings, SafeSearch } from "@/server/domain/value-objects"; import { getContainer } from "@/server/container"; +import { defaultUserSettings, SafeSearch } from "@/server/domain/value-objects"; export const getSetupStatus = createServerFn({ method: "GET" }).handler( async () => { @@ -148,7 +148,8 @@ export const finalizeSetup = createServerFn({ method: "POST" }) userId, settings: { ...defaultUserSettings, - safeSearch: validation.data.safeSearch ?? defaultUserSettings.safeSearch, + safeSearch: + validation.data.safeSearch ?? defaultUserSettings.safeSearch, language: validation.data.language ?? defaultLanguageCode, }, });