diff --git a/libs/auth/src/lib/components/AuthShell.tsx b/libs/auth/src/lib/components/AuthShell.tsx index da86df715..ead99cfeb 100644 --- a/libs/auth/src/lib/components/AuthShell.tsx +++ b/libs/auth/src/lib/components/AuthShell.tsx @@ -7,15 +7,14 @@ import { Stack, Text, Title, - UnstyledButton, rem, useComputedColorScheme, - useMantineColorScheme, useMantineTheme, type BoxProps, } from '@mantine/core'; -import { IconCheck, IconMoon, IconSun } from '@tabler/icons-react'; +import { IconCheck } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; +import { ColorSchemeToggle, LanguageSwitcher } from '@ema-platform/ui'; import { useAuthConfig } from '../AuthConfig'; export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) { @@ -33,35 +32,6 @@ export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number ); } -function ThemeToggle() { - const { t } = useTranslation(); - const { setColorScheme } = useMantineColorScheme(); - const computed = useComputedColorScheme('light'); - const isDark = computed === 'dark'; - - return ( - setColorScheme(isDark ? 'light' : 'dark')} - aria-label={t('authShell.toggleTheme', 'Toggle theme')} - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: rem(36), - height: rem(36), - borderRadius: rem(10), - border: '1px solid var(--mantine-color-default-border)', - background: 'var(--mantine-color-body)', - color: 'var(--mantine-color-dimmed)', - cursor: 'pointer', - transition: 'all 150ms ease', - }} - > - {isDark ? : } - - ); -} - interface AuthShellProps { children: ReactNode; brandTitle?: string; @@ -98,8 +68,9 @@ export function AuthShell({ children, brandTitle, brandSubtitle }: AuthShellProp }} p="md" > - {/* Theme toggle — top-right corner */} - - - + + + (); const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>(); + const handleBack = () => { + if (window.history.length > 1) { + navigate(-1); + } else { + navigate("/"); + } + }; + // Built inside the component (not module scope) so validation messages // pick up the active language — same pattern as ProfilePage's forms. const schema = z.object({ @@ -149,6 +159,32 @@ export function LoginPage() { return ( + { + e.currentTarget.style.color = "var(--mantine-primary-color-filled)"; + e.currentTarget.style.transform = "translateX(-3px)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = "var(--mantine-color-dimmed)"; + e.currentTarget.style.transform = "translateX(0)"; + }} + > + + {t("common.back", "Back")} + +
{t("login.welcome", { appName, defaultValue: "Welcome to {{appName}}" })} diff --git a/libs/auth/src/lib/pages/SignupPage.tsx b/libs/auth/src/lib/pages/SignupPage.tsx index 2ce426e1f..4017ded9d 100644 --- a/libs/auth/src/lib/pages/SignupPage.tsx +++ b/libs/auth/src/lib/pages/SignupPage.tsx @@ -11,8 +11,10 @@ import { Text, TextInput, Title, + UnstyledButton, } from '@mantine/core'; import { + IconArrowLeft, IconArrowRight, IconAt, IconDeviceMobile, @@ -61,6 +63,14 @@ export function SignupPage() { }>(); const [meTrigger] = useApiMutation<AuthUser>(); + const handleBack = () => { + if (window.history.length > 1) { + navigate(-1); + } else { + navigate('/'); + } + }; + // Order matches passwordRules' default list: length, lowercase, uppercase, // number, special character. Shared between the zod schema (field error) // and the live checklist below, so both agree on the wording. @@ -155,6 +165,32 @@ export function SignupPage() { })} > <Stack gap="lg"> + <UnstyledButton + onClick={handleBack} + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: 6, + fontSize: 14, + fontWeight: 600, + color: 'var(--mantine-color-dimmed)', + cursor: 'pointer', + width: 'fit-content', + transition: 'all 150ms ease', + }} + onMouseEnter={(e) => { + e.currentTarget.style.color = 'var(--mantine-primary-color-filled)'; + e.currentTarget.style.transform = 'translateX(-3px)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = 'var(--mantine-color-dimmed)'; + e.currentTarget.style.transform = 'translateX(0)'; + }} + > + <IconArrowLeft size={18} /> + {t('common.back', 'Back')} + </UnstyledButton> + <div> <Title order={2} fz={30}> {t('signup.title', 'Create account')} diff --git a/libs/ui/src/lib/layout/ColorSchemeToggle.tsx b/libs/ui/src/lib/layout/ColorSchemeToggle.tsx index c31109394..b8790318a 100644 --- a/libs/ui/src/lib/layout/ColorSchemeToggle.tsx +++ b/libs/ui/src/lib/layout/ColorSchemeToggle.tsx @@ -1,45 +1,118 @@ -import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core'; -import { IconSun, IconMoon } from '@tabler/icons-react'; +import { + Menu, + UnstyledButton, + Text, + Group, + rem, + useMantineColorScheme, + useComputedColorScheme, +} from '@mantine/core'; +import { + IconSun, + IconMoon, + IconDeviceDesktop, + IconCheck, +} from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; export function ColorSchemeToggle() { const { t } = useTranslation(); - const { setColorScheme } = useMantineColorScheme(); + const { colorScheme, setColorScheme } = useMantineColorScheme(); const computed = useComputedColorScheme('light', { getInitialValueInEffect: true }); const isDark = computed === 'dark'; + const themes = [ + { + value: 'light' as const, + label: t('profile.appearance.light', 'Light'), + icon: IconSun, + color: '#e67e22', + }, + { + value: 'dark' as const, + label: t('profile.appearance.dark', 'Dark'), + icon: IconMoon, + color: '#9b59b6', + }, + { + value: 'auto' as const, + label: t('profile.appearance.system', 'System'), + icon: IconDeviceDesktop, + color: '#3498db', + }, + ]; + + const CurrentIcon = colorScheme === 'auto' ? IconDeviceDesktop : isDark ? IconMoon : IconSun; + return ( - <UnstyledButton - aria-label={t('common.toggleTheme')} - onClick={() => setColorScheme(isDark ? 'light' : 'dark')} - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: rem(38), - height: rem(38), - borderRadius: rem(12), - background: 'var(--mantine-color-body)', - color: 'var(--mantine-color-gray-6)', - boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)', - transition: 'all 150ms ease', - cursor: 'pointer', - border: 'none', - }} - onMouseEnter={(e) => { - e.currentTarget.style.background = 'var(--mantine-primary-color-light)'; - e.currentTarget.style.color = 'var(--mantine-primary-color-6)'; - e.currentTarget.style.boxShadow = - '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = 'var(--mantine-color-body)'; - e.currentTarget.style.color = 'var(--mantine-color-gray-6)'; - e.currentTarget.style.boxShadow = - '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)'; - }} - > - {isDark ? <IconSun size={19} /> : <IconMoon size={19} />} - </UnstyledButton> + <Menu shadow="lg" width={170} position="bottom-end" withinPortal transitionProps={{ transition: 'pop-top-right', duration: 150 }}> + <Menu.Target> + <UnstyledButton + aria-label={t('common.toggleTheme', 'Toggle light / dark mode')} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: rem(38), + height: rem(38), + borderRadius: rem(12), + background: 'var(--mantine-color-body)', + color: isDark ? 'var(--mantine-color-yellow-4)' : 'var(--mantine-color-blue-6)', + boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)', + transition: 'all 200ms ease', + cursor: 'pointer', + border: 'none', + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-1px)'; + e.currentTarget.style.boxShadow = + '0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px var(--mantine-primary-color-3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = + '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)'; + }} + > + <CurrentIcon size={19} stroke={1.8} /> + </UnstyledButton> + </Menu.Target> + + <Menu.Dropdown + style={{ + borderRadius: rem(14), + padding: rem(6), + boxShadow: '0 10px 30px rgba(0,0,0,0.15)', + backdropFilter: 'blur(8px)', + }} + > + <Menu.Label style={{ fontWeight: 600, fontSize: rem(11), letterSpacing: '0.5px', textTransform: 'uppercase' }}> + {t('profile.appearance.title', 'Appearance')} + </Menu.Label> + {themes.map((theme) => { + const Icon = theme.icon; + const isSelected = colorScheme === theme.value; + return ( + <Menu.Item + key={theme.value} + onClick={() => setColorScheme(theme.value)} + leftSection={<Icon size={16} stroke={1.8} style={{ color: isSelected ? 'var(--mantine-primary-color-filled)' : 'currentColor' }} />} + rightSection={ + isSelected ? <IconCheck size={16} stroke={2.4} style={{ color: 'var(--mantine-primary-color-filled)' }} /> : undefined + } + style={{ + borderRadius: rem(8), + fontWeight: isSelected ? 600 : 400, + backgroundColor: isSelected ? 'var(--mantine-primary-color-light)' : undefined, + color: isSelected ? 'var(--mantine-primary-color-filled)' : undefined, + }} + > + <Text size="sm">{theme.label}</Text> + </Menu.Item> + ); + })} + </Menu.Dropdown> + </Menu> ); } + diff --git a/libs/ui/src/lib/layout/LanguageSwitcher.tsx b/libs/ui/src/lib/layout/LanguageSwitcher.tsx index b1ec6305d..0e9df453d 100644 --- a/libs/ui/src/lib/layout/LanguageSwitcher.tsx +++ b/libs/ui/src/lib/layout/LanguageSwitcher.tsx @@ -1,83 +1,179 @@ -import { Menu, UnstyledButton, Text, rem } from '@mantine/core'; -import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react'; +import type { ComponentType, SVGProps } from 'react'; +import { Menu, UnstyledButton, Text, Group, rem } from '@mantine/core'; +import { IconCheck, IconChevronDown, IconWorld } from '@tabler/icons-react'; +import { GB, ET } from 'country-flag-icons/react/3x2'; import { useTranslation } from 'react-i18next'; interface LanguageSwitcherProps { supportedLanguages: readonly string[]; - /** `icon` renders a compact globe button; `button` shows the language label. */ + /** `icon` renders a compact flag button; `button` shows the language label. */ variant?: 'icon' | 'button'; } -export function LanguageSwitcher({ supportedLanguages, variant = 'icon' }: LanguageSwitcherProps) { +type FlagComponentType = ComponentType<SVGProps<SVGSVGElement>>; + +const LANGUAGE_CONFIG: Record< + string, + { Flag: FlagComponentType; nativeName: string; shortCode: string } +> = { + en: { Flag: GB, nativeName: 'English', shortCode: 'EN' }, + am: { Flag: ET, nativeName: 'አማርኛ', shortCode: 'AM' }, +}; + +export function LanguageSwitcher({ + supportedLanguages, + variant = 'icon', +}: LanguageSwitcherProps) { const { t, i18n } = useTranslation(); - const current = i18n.language; + const current = i18n.language || 'en'; const change = (lng: string) => { if (lng !== current) i18n.changeLanguage(lng); }; + const currentConfig = LANGUAGE_CONFIG[current] || { + Flag: null, + nativeName: current.toUpperCase(), + shortCode: current.toUpperCase(), + }; + + const CurrentFlag = currentConfig.Flag; + return ( - <Menu shadow="md" width={160} position="bottom-end" withinPortal> + <Menu + shadow="lg" + width={190} + position="bottom-end" + withinPortal + transitionProps={{ transition: 'pop-top-right', duration: 150 }} + > <Menu.Target> <UnstyledButton - aria-label={t('language.label')} + aria-label={t('language.label', 'Language')} style={{ - display: 'flex', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - gap: rem(4), + gap: rem(6), width: variant === 'icon' ? rem(38) : 'auto', height: rem(38), padding: variant === 'icon' ? 0 : '0 12px', borderRadius: rem(12), background: 'var(--mantine-color-body)', - color: 'var(--mantine-color-gray-6)', - boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)', - transition: 'all 150ms ease', + color: 'var(--mantine-color-gray-7)', + boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)', + transition: 'all 200ms ease', cursor: 'pointer', border: 'none', }} onMouseEnter={(e) => { - e.currentTarget.style.background = 'var(--mantine-primary-color-light)'; - e.currentTarget.style.color = 'var(--mantine-primary-color-6)'; + e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = - '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)'; + '0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px var(--mantine-primary-color-3)'; }} onMouseLeave={(e) => { - e.currentTarget.style.background = 'var(--mantine-color-body)'; - e.currentTarget.style.color = 'var(--mantine-color-gray-6)'; + e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = - '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)'; + '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)'; }} > - <IconWorld size={19} /> + {CurrentFlag ? ( + <CurrentFlag + style={{ + width: rem(20), + height: rem(14), + borderRadius: rem(2), + display: 'block', + boxShadow: '0 1px 2px rgba(0,0,0,0.2)', + }} + /> + ) : ( + <IconWorld size={19} /> + )} + {variant !== 'icon' && ( - <> - <Text size="sm" fw={500}> - {t(`language.${current}`)} + <Group gap={6} wrap="nowrap"> + <Text size="sm" fw={600}> + {t(`language.${current}`, currentConfig.nativeName)} </Text> - <IconChevronDown size={14} /> - </> + <IconChevronDown size={14} stroke={2} style={{ opacity: 0.7 }} /> + </Group> )} </UnstyledButton> </Menu.Target> <Menu.Dropdown - style={{ borderRadius: rem(12), padding: rem(6) }} + style={{ + borderRadius: rem(14), + padding: rem(6), + boxShadow: '0 10px 30px rgba(0,0,0,0.15)', + backdropFilter: 'blur(8px)', + }} > - <Menu.Label>{t('language.label')}</Menu.Label> - {supportedLanguages.map((lng) => ( - <Menu.Item - key={lng} - onClick={() => change(lng)} - rightSection={ - current === lng ? <IconCheck size={16} /> : undefined - } - style={{ borderRadius: rem(8) }} - > - {t(`language.${lng}`)} - </Menu.Item> - ))} + <Menu.Label + style={{ + fontWeight: 600, + fontSize: rem(11), + letterSpacing: '0.5px', + textTransform: 'uppercase', + }} + > + {t('language.label', 'Language')} + </Menu.Label> + {supportedLanguages.map((lng) => { + const config = LANGUAGE_CONFIG[lng] || { + Flag: null, + nativeName: lng.toUpperCase(), + shortCode: lng.toUpperCase(), + }; + const Flag = config.Flag; + const isSelected = current === lng; + + return ( + <Menu.Item + key={lng} + onClick={() => change(lng)} + leftSection={ + Flag ? ( + <Flag + style={{ + width: rem(20), + height: rem(14), + borderRadius: rem(2), + display: 'block', + boxShadow: '0 1px 2px rgba(0,0,0,0.15)', + }} + /> + ) : ( + <IconWorld size={16} /> + ) + } + rightSection={ + isSelected ? ( + <IconCheck + size={16} + stroke={2.4} + style={{ color: 'var(--mantine-primary-color-filled)' }} + /> + ) : undefined + } + style={{ + borderRadius: rem(8), + fontWeight: isSelected ? 600 : 400, + backgroundColor: isSelected + ? 'var(--mantine-primary-color-light)' + : undefined, + color: isSelected ? 'var(--mantine-primary-color-filled)' : undefined, + }} + > + <Group justify="space-between" wrap="nowrap" style={{ width: '100%' }}> + <Text size="sm">{t(`language.${lng}`, config.nativeName)}</Text> + </Group> + </Menu.Item> + ); + })} </Menu.Dropdown> </Menu> ); } + +