diff --git a/common/src/constants.ts b/common/src/constants.ts
index 8d1b773f..0efd4ca4 100644
--- a/common/src/constants.ts
+++ b/common/src/constants.ts
@@ -27,3 +27,11 @@ export const WEB_GOOGLE_CLIENT_ID = '253367029065-khkj31qt22l0vc3v754h09vhpg6t33
// export const ANDROID_GOOGLE_CLIENT_ID = '253367029065-s9sr5vqgkhc8f7p5s6ti6a4chqsrqgc4.apps.googleusercontent.com'
export const GOOGLE_CLIENT_ID = WEB_GOOGLE_CLIENT_ID
+export const defaultLocale = 'en'
+export const LOCALES = {
+ en: "English",
+ fr: "Français",
+ // es: "Español",
+}
+export const supportedLocales = Object.keys(LOCALES)
+export type Locale = typeof supportedLocales[number]
\ No newline at end of file
diff --git a/web/components/home/home.tsx b/web/components/home/home.tsx
index cc68b3ac..020f74d6 100644
--- a/web/components/home/home.tsx
+++ b/web/components/home/home.tsx
@@ -2,6 +2,7 @@ import {useEffect} from "react";
import {Col} from "web/components/layout/col";
import {SignUpButton} from "web/components/nav/sidebar";
import {useUser} from "web/hooks/use-user";
+import {useT} from "web/lib/locale";
export function AboutBox(props: {
title: string
@@ -20,6 +21,7 @@ export function AboutBox(props: {
export function LoggedOutHome() {
const user = useUser()
+ const t = useT()
useEffect(() => {
const text = "Search.";
@@ -80,8 +82,7 @@ export function LoggedOutHome() {
- Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to
- browsing — a public digital good designed to serve people, not profit.
+ {t('home.bottom', 'Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to browsing — a public digital good designed to serve people, not profit.')}
diff --git a/web/components/language/language-picker.tsx b/web/components/language/language-picker.tsx
new file mode 100644
index 00000000..47cc3032
--- /dev/null
+++ b/web/components/language/language-picker.tsx
@@ -0,0 +1,24 @@
+'use client'
+
+import {useLocale} from "web/lib/locale";
+import {LOCALES} from "common/constants";
+
+
+export function LanguagePicker() {
+ const {locale, setLocale} = useLocale()
+
+ return (
+
+ )
+}
diff --git a/web/lib/locale-cookie.ts b/web/lib/locale-cookie.ts
new file mode 100644
index 00000000..db31a4bb
--- /dev/null
+++ b/web/lib/locale-cookie.ts
@@ -0,0 +1,53 @@
+import type {IncomingMessage} from 'http'
+import {defaultLocale, supportedLocales, Locale} from "common/constants"
+
+let cachedLocale: string | null | undefined = null
+
+export function getLocale(req?: IncomingMessage): string {
+ // if (cachedLocale) return cachedLocale
+ console.log('cachedLocale', cachedLocale)
+ let cookie = null
+ // Server
+ if (req?.headers?.cookie) {
+ cookie = req.headers.cookie
+ }
+
+ // Client
+ if (typeof document !== 'undefined') {
+ cookie = document.cookie
+ }
+
+ if (cookie) {
+ console.log('Cookie', cookie)
+ cachedLocale = cookie
+ .split(' ')
+ .find(c => c.startsWith('lang='))
+ ?.split('=')[1]
+ ?.split(' ')[0]
+ console.log('Locale cookie', cachedLocale)
+ }
+
+ if (!cachedLocale) {
+ cachedLocale = getBrowserLocale()
+ }
+
+ console.log('Locale cookie browser', getBrowserLocale())
+
+ return cachedLocale ?? defaultLocale
+}
+
+export function getBrowserLocale(): Locale | null {
+ if (typeof navigator === 'undefined') return null
+
+ const languages = navigator.languages ?? [navigator.language]
+ console.log('Browser languages', languages, navigator.language)
+
+ for (const lang of languages) {
+ const base = lang.split('-')[0] as Locale
+ if (supportedLocales.includes(base)) {
+ return base
+ }
+ }
+
+ return null
+}
diff --git a/web/lib/locale.ts b/web/lib/locale.ts
new file mode 100644
index 00000000..7cc704b2
--- /dev/null
+++ b/web/lib/locale.ts
@@ -0,0 +1,44 @@
+import {createContext, useContext, useEffect, useState} from 'react'
+import {defaultLocale} from "common/constants";
+
+
+export type I18nContextType = {
+ locale: string
+ setLocale: (locale: string) => void
+}
+
+export const I18nContext = createContext({
+ locale: defaultLocale,
+ setLocale: () => {}
+})
+
+export function useLocale() {
+ return useContext(I18nContext)
+}
+
+// export function t(key: string, english: string): string {
+// const locale = useLocale()
+// console.log({locale})
+//
+// if (locale === defaultLocale) return english
+// return messages[locale]?.[key] ?? english
+// }
+
+export function useT() {
+ const {locale} = useLocale()
+ console.log({locale})
+ const [messages, setMessages] = useState>({})
+
+ useEffect(() => {
+ if (locale === defaultLocale) return
+
+ import(`web/messages/${locale}.json`)
+ .then((mod) => setMessages(mod.default))
+ .catch(() => setMessages({}))
+ }, [locale])
+
+ return (key: string, english: string) => {
+ if (locale === defaultLocale) return english
+ return messages[key] ?? english
+ }
+}
diff --git a/web/messages/fr.json b/web/messages/fr.json
new file mode 100644
index 00000000..49c5c725
--- /dev/null
+++ b/web/messages/fr.json
@@ -0,0 +1,34 @@
+{
+ "settings.title": "Paramètres",
+ "settings.tabs.general": "Général",
+ "settings.tabs.notifications": "Notifications",
+ "settings.tabs.about": "A propos",
+ "settings.general.theme": "Thème",
+ "settings.general.language": "Langue",
+ "settings.general.account": "Compte",
+ "settings.general.email": "Courriel",
+ "settings.email.verified": "Courriel vérifié ✔️",
+ "settings.email.send_verification": "Envoyer un courriel de vérification",
+ "settings.email.change": "Modifier l'adresse e‑mail",
+ "settings.email.new_placeholder": "Nouvelle adresse e‑mail",
+ "settings.email.required": "Le courriel est requis",
+ "settings.email.invalid": "Adresse e‑mail invalide",
+ "settings.action.save": "Enregistrer",
+ "settings.action.cancel": "Annuler",
+ "settings.general.password": "Mot de passe",
+ "settings.password.send_reset": "Envoyer un e‑mail de réinitialisation de mot de passe",
+ "settings.danger_zone": "Zone dangereuse",
+ "settings.delete_account": "Supprimer le compte",
+ "settings.delete_confirm": "Êtes-vous sûr de vouloir supprimer votre profil ? Cela ne peut pas être annulé.",
+ "settings.delete.loading": "Suppression du compte...",
+ "settings.delete.success": "Votre compte a été supprimé.",
+ "settings.delete.error": "Échec de la suppression du compte.",
+ "settings.email.updated_success": "Adresse e‑mail mise à jour avec succès",
+ "settings.email.update_failed": "Échec de la mise à jour de l'adresse e‑mail",
+ "settings.email.same_as_current": "Le nouveau courriel est identique à l'actuel",
+ "settings.email.no_email": "Aucun courriel trouvé sur votre compte.",
+ "settings.email.must_sign_in": "Vous devez être connecté pour envoyer un courriel de vérification.",
+ "settings.email.sending": "Envoi du courriel de vérification...",
+ "settings.email.verification_sent": "Courriel de vérification envoyé — vérifiez votre boîte de réception et les spams.",
+ "settings.email.verification_failed": "Échec de l'envoi du courriel de vérification."
+}
\ No newline at end of file
diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx
index a71753ee..e2c48d0a 100644
--- a/web/pages/_app.tsx
+++ b/web/pages/_app.tsx
@@ -1,6 +1,6 @@
-import type {AppProps} from 'next/app'
+import type {AppContext, AppProps} from 'next/app'
import Head from 'next/head'
-import {useEffect} from 'react'
+import {useEffect, useState} from 'react'
import {Router} from 'next/router'
import posthog from 'posthog-js'
import {PostHogProvider} from 'posthog-js/react'
@@ -20,6 +20,8 @@ import {useRouter} from "next/navigation"
import {Keyboard} from "@capacitor/keyboard"
import {LiveUpdate} from "@capawesome/capacitor-live-update"
import {IS_VERCEL} from "common/hosting/constants"
+import {getLocale} from "web/lib/locale-cookie";
+import {I18nContext} from "web/lib/locale"
if (Capacitor.isNativePlatform()) {
// Only runs on iOS/Android native
@@ -85,11 +87,18 @@ function printBuildInfo() {
// specially treated props that may be present in the server/static props
type PageProps = { auth?: AuthUser }
-function MyApp({Component, pageProps}: AppProps) {
+function MyApp(props: AppProps & { locale: string }) {
+ const {Component, pageProps} = props
useEffect(printBuildInfo, [])
useHasLoaded()
const router = useRouter()
+ const [locale, setLocaleState] = useState(props.locale);
+ const setLocale = (newLocale: string) => {
+ document.cookie = `lang=${newLocale}; path=/; max-age=31536000`;
+ setLocaleState(newLocale);
+ };
+
useEffect(() => {
console.log('isAndroidWebView app:', isAndroidApp())
if (!Capacitor.isNativePlatform()) return
@@ -142,6 +151,7 @@ function MyApp({Component, pageProps}: AppProps) {
{title}
+
) {
-
+
+
+
{/* Workaround for https://github.com/tailwindlabs/headlessui/discussions/666, to allow font CSS variable */}
@@ -193,10 +205,18 @@ function MyApp({Component, pageProps}: AppProps) {
- {/* TODO: Re-enable one tap setup */}
- {/* */}
>
)
}
+MyApp.getInitialProps = async (appContext: AppContext) => {
+ const appProps = await import('next/app').then(m => m.default.getInitialProps(appContext))
+ const locale = getLocale(appContext.ctx.req)
+
+ return {
+ ...appProps,
+ locale
+ };
+};
+
export default MyApp
diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx
index 136ed9ef..606b1e27 100644
--- a/web/pages/_document.tsx
+++ b/web/pages/_document.tsx
@@ -46,8 +46,8 @@ export default function Document() {
"body-bg text-ink-1000",
'safe-top',
)}>
-
-
+
+
)
diff --git a/web/pages/settings.tsx b/web/pages/settings.tsx
index bfbca463..a73dbde3 100644
--- a/web/pages/settings.tsx
+++ b/web/pages/settings.tsx
@@ -19,19 +19,22 @@ import ThemeIcon from "web/components/theme-icon";
import {WithPrivateUser} from "web/components/user/with-user";
import {sendPasswordReset} from "web/lib/firebase/password";
import {AboutSettings} from "web/components/about-settings";
+import {LanguagePicker} from "web/components/language/language-picker";
+import {useT} from "web/lib/locale";
export default function NotificationsPage() {
+ const t = useT()
useRedirectIfSignedOut()
return (
- Settings
+ {t('settings.title','Settings')}},
- {title: 'Notifications', content: },
- {title: 'About', content: },
+ {title: t('settings.tabs.general','General'), content: },
+ {title: t('settings.tabs.notifications', 'Notifications'), content: },
+ {title: t('settings.tabs.about', 'About'), content: },
]}
trackingName={'settings page'}
/>
@@ -52,24 +55,23 @@ const LoadedGeneralSettings = (props: {
const [isChangingEmail, setIsChangingEmail] = useState(false)
const {register, handleSubmit, formState: {errors}, reset} = useForm<{ newEmail: string }>()
+ const t = useT()
const user = auth.currentUser
if (!user) return null
const handleDeleteAccount = async () => {
- const confirmed = confirm(
- 'Are you sure you want to delete your profile? This cannot be undone.'
- )
+ const confirmed = confirm(t('settings.delete_confirm', "Are you sure you want to delete your profile? This cannot be undone."))
if (confirmed) {
toast
.promise(deleteAccount(), {
- loading: 'Deleting account...',
+ loading: t('settings.delete.loading', 'Deleting account...'),
success: () => {
router.push('/')
- return 'Your account has been deleted.'
+ return t('settings.delete.success', 'Your account has been deleted.')
},
error: () => {
- return 'Failed to delete account.'
+ return t('settings.delete.error', 'Failed to delete account.')
},
})
.catch(() => {
@@ -83,21 +85,21 @@ const LoadedGeneralSettings = (props: {
try {
await updateEmail(user, newEmail)
- toast.success('Email updated successfully')
+ toast.success(t('settings.email.updated_success', 'Email updated successfully'))
setIsChangingEmail(false)
reset()
// Force a reload to update the UI with the new email
// window.location.reload()
} catch (error: any) {
console.error('Error updating email:', error)
- toast.error(error.message || 'Failed to update email')
+ toast.error(error.message || t('settings.email.update_failed', 'Failed to update email'))
}
}
const onSubmitEmailChange = (data: { newEmail: string }) => {
if (!user) return
if (data.newEmail === user.email) {
- toast.error('New email is the same as current email')
+ toast.error(t('settings.email.same_as_current', 'New email is the same as current email'))
return
}
changeUserEmail(data.newEmail)
@@ -106,18 +108,18 @@ const LoadedGeneralSettings = (props: {
const sendVerificationEmail = async () => {
if (!privateUser?.email) {
- toast.error('No email found on your account.')
+ toast.error(t('settings.email.no_email', 'No email found on your account.'))
return
}
if (!user) {
- toast.error('You must be signed in to send a verification email.')
+ toast.error(t('settings.email.must_sign_in', 'You must be signed in to send a verification email.'))
return
}
toast
.promise(sendEmailVerification(user), {
- loading: 'Sending verification email...',
- success: 'Verification email sent — check your inbox and spam.',
- error: 'Failed to send verification email.',
+ loading: t('settings.email.sending', 'Sending verification email...'),
+ success: t('settings.email.verification_sent', 'Verification email sent — check your inbox and spam.'),
+ error: t('settings.email.verification_failed', 'Failed to send verification email.'),
})
.catch(() => {
console.log("Failed to send verification email")
@@ -128,41 +130,47 @@ const LoadedGeneralSettings = (props: {
return <>