feat: add language selector to login page

This commit is contained in:
theborakompanioni 2026-08-12 12:05:31 +02:00
parent 813989e7b7
commit 45dbccb036
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
6 changed files with 109 additions and 18 deletions

View file

@ -1,9 +1,10 @@
import { useState, type ComponentProps } from 'react'
import { RefreshCwIcon, WalletIcon } from 'lucide-react'
import { LanguagesIcon, RefreshCwIcon, WalletIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { LanguageSelectorDropdownMenu } from '@/components/ui/jam/LanguageSelector'
import { WalletLoadErrorAlert } from '@/components/ui/jam/WalletLoadErrorAlert'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
@ -71,22 +72,36 @@ export const LoginCard = ({
<>
<Card className="w-full max-w-md">
<CardHeader className="flex flex-col items-center space-y-2">
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
{listWalletsFetching ? (
<Spinner className="size-6" />
) : (
<div className="mb-4 grid w-full grid-cols-3">
<div></div>
<Button
type="button"
variant="default"
size="icon-xxl"
className="bg-primary/10 text-primary/80 hover:text-primary self-center justify-self-center rounded-full"
title={t('global.refresh')}
aria-label={t('global.refresh')}
onClick={() => void onReloadClick()}
disabled={listWalletsFetching}
>
{listWalletsFetching ? <Spinner className="size-6" /> : <WalletIcon className="size-5" />}
<span className="sr-only">{t('global.refresh')}</span>
</Button>
<LanguageSelectorDropdownMenu align="end">
<Button
type="button"
variant="ghost"
size="icon"
className="text-primary h-10 w-10 rounded-full"
title={t('global.retry')}
aria-label={t('global.retry')}
onClick={() => void onReloadClick()}
aria-label={t('settings.label_select_language_aria_label')}
title={t('settings.label_select_language_title')}
className="self-start justify-self-end"
>
<WalletIcon />
<LanguagesIcon className="size-5" />
<span className="sr-only">{t('settings.label_select_language')}</span>
</Button>
)}
</LanguageSelectorDropdownMenu>
</div>
<CardTitle className="text-center text-2xl font-bold break-words">{t('login.title')}</CardTitle>
{listWalletsLoading ? (
@ -103,7 +118,7 @@ export const LoginCard = ({
) : null}
</CardHeader>
<CardContent className="space-y-6">
<CardContent className="flex flex-col gap-6">
{listWalletsError ? (
<>
<WalletLoadErrorAlert reason={getErrorReason(listWalletsError, t('global.errors.reason_unknown'))} />

View file

@ -59,6 +59,9 @@ vi.mock('next-themes', () => ({
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
resolvedLanguage: 'en',
},
t: (key: string) => key,
}),
}))
@ -123,7 +126,7 @@ vi.mock('@/components/settings/AccountXpubsDialog', () => ({
) : null,
}))
vi.mock('@/components/settings/LanguageSelector', () => ({
vi.mock('@/components/ui/jam/LanguageSelector', () => ({
LanguageSelector: () => <div>language-selector</div>,
}))

View file

@ -26,8 +26,10 @@ import { useNavigate, type NavigateFunction } from 'react-router-dom'
import { useStore } from 'zustand'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { LanguageSelector } from '@/components/ui/jam/LanguageSelector'
import PageTitle from '@/components/ui/jam/PageTitle'
import { Separator } from '@/components/ui/separator'
import { Spinner } from '@/components/ui/spinner'
import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
import { JAM_DOCS_URL, JAM_MATRIX_URL, JAM_REPO_URL, JAM_SEED_MODAL_TIMEOUT, JAM_TELEGRAM_URL } from '@/constants/jam'
import { routes } from '@/constants/routes'
@ -37,8 +39,6 @@ import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { cn, type WalletFileName } from '@/lib/utils'
import { authStore } from '@/store/authStore'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { LanguageSelector } from '../ui/jam/LanguageSelector'
import { Spinner } from '../ui/spinner'
import { AccountXpubsDialog } from './AccountXpubsDialog'
import { SeedPhraseDialog } from './SeedPhraseDialog'
import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem'

View file

@ -40,6 +40,7 @@ const buttonVariants = cva(
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
'icon-xxl': 'size-12',
},
},
defaultVariants: {

View file

@ -1,4 +1,4 @@
import type { ComponentProps } from 'react'
import type { ComponentProps, PropsWithChildren } from 'react'
import { LanguagesIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
@ -11,8 +11,22 @@ import {
SelectLabel,
} from '@/components/ui/select'
import languages from '@/i18n/languages'
import { cn } from '@/lib/utils'
import { Button } from '../button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '../dropdown-menu'
export const LanguageSelector = (props: ComponentProps<typeof Select> = {}) => {
export const LanguageSelector = ({
className,
...props
}: ComponentProps<typeof Select> & { className?: string } = {}) => {
const { i18n, t } = useTranslation()
const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage)
const currentLanguageDescription = currentLanguage?.description || 'English'
@ -25,7 +39,10 @@ export const LanguageSelector = (props: ComponentProps<typeof Select> = {}) => {
}}
{...props}
>
<SelectTrigger className="w-full text-xs sm:min-w-42" aria-label="Select language">
<SelectTrigger
className={cn('w-full text-xs sm:min-w-42', className)}
aria-label={t('settings.label_select_language_aria_label')}
>
<SelectValue placeholder={currentLanguageDescription} />
</SelectTrigger>
<SelectContent>
@ -44,3 +61,56 @@ export const LanguageSelector = (props: ComponentProps<typeof Select> = {}) => {
</Select>
)
}
export const LanguageSelectorDropdownMenu = ({
className,
align,
loop = true,
...props
}: PropsWithChildren<
ComponentProps<typeof DropdownMenu> & {
className?: string
align?: ComponentProps<typeof DropdownMenuContent>['align']
loop?: ComponentProps<typeof DropdownMenuContent>['loop']
}
> = {}) => {
const { i18n, t } = useTranslation()
return (
<DropdownMenu {...props}>
<DropdownMenuTrigger asChild>
{props.children ?? (
<Button
type="button"
variant="ghost"
className={className}
aria-label={t('settings.label_select_language_aria_label')}
>
<LanguagesIcon />
{t('settings.label_select_language')}
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full text-xs sm:min-w-56" align={align} loop={loop}>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-1">
<LanguagesIcon className="size-4" />
{t('settings.label_select_language')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={i18n.resolvedLanguage}
onValueChange={(value) => {
void i18n.changeLanguage(value)
}}
>
{languages.map((language) => (
<DropdownMenuRadioItem key={language.key} value={language.key}>
{language.description}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -293,6 +293,8 @@
"use_address_chunking_enabled": "Address display chunking enabled",
"use_address_chunking_disabled": "Address display chunking disabled",
"label_select_language": "Language",
"label_select_language_title": "Select language",
"label_select_language_aria_label": "$t(settings.label_select_language_title)",
"show_seed": "Show seed phrase",
"show_xpubs": "Show account xpubs",
"reveal_seed": "Reveal seed phrase",