From fb4d4efe662158eef96fc810812b4d64e97ce12e Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 11 Aug 2026 17:42:23 -0400 Subject: [PATCH 1/4] Add report bug functionality and settings sections refactor --- public/locales/en/common.json | 3 +- src/constants/settingsSections.js | 34 +++++++ src/search/GlobalSearchContext.jsx | 5 +- src/search/searchProviders.js | 32 ++----- src/views/Settings/SettingsOverview.jsx | 121 +++--------------------- src/views/components/AddTaskModal.jsx | 79 +++++++++++++++- src/views/components/NavBar.jsx | 18 ++++ 7 files changed, 155 insertions(+), 137 deletions(-) create mode 100644 src/constants/settingsSections.js diff --git a/public/locales/en/common.json b/public/locales/en/common.json index dce97a7..f3962ff 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -29,7 +29,8 @@ "filters": "Filters", "activities": "Activities", "points": "Points", - "settings": "Settings" + "settings": "Settings", + "reportBug": "Report a Bug" }, "feedback": { "later": "Maybe later", diff --git a/src/constants/settingsSections.js b/src/constants/settingsSections.js new file mode 100644 index 0000000..3d64d8c --- /dev/null +++ b/src/constants/settingsSections.js @@ -0,0 +1,34 @@ +import { + AccountCircle, + Api, + Circle, + Code, + FamilyRestroom, + Language, + Notifications, + Palette, + Person, + Security, + Settings, + Storage, + ViewSidebar, +} from '@mui/icons-material' + +// Single source of truth for the settings sections: id, icon, and access +// gating. Titles/descriptions live in locales/settings.json under +// `overview.sections.`, keyed off the same ids. +export const SETTINGS_SECTIONS = [ + { id: 'profile', icon: Person }, + { id: 'circle', icon: Circle, parentOnly: true }, + { id: 'account', icon: AccountCircle, parentOnly: true }, + { id: 'subaccounts', icon: FamilyRestroom }, + { id: 'notifications', icon: Notifications }, + { id: 'mfa', icon: Security, parentOnly: true }, + { id: 'apitokens', icon: Api, parentOnly: true }, + { id: 'storage', icon: Storage }, + { id: 'sidepanel', icon: ViewSidebar }, + { id: 'theme', icon: Palette }, + { id: 'localization', icon: Language, isBeta: true }, + { id: 'advanced', icon: Settings }, + { id: 'developer', icon: Code }, +] diff --git a/src/search/GlobalSearchContext.jsx b/src/search/GlobalSearchContext.jsx index 3ebae49..eabc6ed 100644 --- a/src/search/GlobalSearchContext.jsx +++ b/src/search/GlobalSearchContext.jsx @@ -8,6 +8,7 @@ import { useMemo, useState, } from 'react' +import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' import { offlineDB } from '../utils/OfflineDB' @@ -34,6 +35,7 @@ const uniqueBy = (items, getId) => [ export const GlobalSearchProvider = ({ children }) => { const queryClient = useQueryClient() + const { t } = useTranslation('settings') const location = useLocation() const navigate = useNavigate() const isMobile = useMediaQuery('(max-width:768px)') @@ -110,6 +112,7 @@ export const GlobalSearchProvider = ({ children }) => { labels, members, isParent: isParentUser(profile), + t, choresById: new Map(chores.map(item => [String(item.id), item])), projectsById: new Map(projects.map(item => [String(item.id), item])), membersById: new Map(members.map(item => [String(item.userId), item])), @@ -127,7 +130,7 @@ export const GlobalSearchProvider = ({ children }) => { } finally { setIsLoading(false) } - }, [queryClient]) + }, [queryClient, t]) const openSearch = useCallback( (query = '') => { diff --git a/src/search/searchProviders.js b/src/search/searchProviders.js index 7b61a54..13f5288 100644 --- a/src/search/searchProviders.js +++ b/src/search/searchProviders.js @@ -1,3 +1,5 @@ +import { SETTINGS_SECTIONS } from '../constants/settingsSections' + const stripHtml = value => { if (!value) return '' if (typeof globalThis.document === 'undefined') @@ -17,26 +19,6 @@ const HISTORY_STATUS = { 6: 'rescheduled', } -const SETTINGS = [ - ['profile', 'Profile', 'Name, avatar and personal details'], - ['circle', 'Circle', 'Members and household settings', true], - ['account', 'Account', 'Subscription and account management', true], - ['subaccounts', 'Subaccounts', 'Manage child accounts'], - ['notifications', 'Notifications', 'Reminders and notification preferences'], - ['mfa', 'Multi-factor authentication', 'Secure your account', true], - ['apitokens', 'API tokens', 'Manage integrations and access tokens', true], - ['storage', 'Storage', 'Files, backups and device storage'], - ['sidepanel', 'Side panel', 'Customize navigation'], - ['theme', 'Appearance', 'Theme, dark mode and colors'], - ['localization', 'Language and region', 'Language, dates and time formats'], - [ - 'advanced', - 'Advanced settings', - 'Offline support, webhooks and application behavior', - ], - ['developer', 'Developer settings', 'Diagnostics and experimental tools'], -] - const providers = [] export const registerSearchProvider = provider => { @@ -166,15 +148,15 @@ registerSearchProvider({ registerSearchProvider({ id: 'settings', - getDocuments: ({ isParent }) => - SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map( - ([id, title, description]) => + getDocuments: ({ isParent, t }) => + SETTINGS_SECTIONS.filter(({ parentOnly }) => !parentOnly || isParent).map( + ({ id }) => document('settings', { id: `setting:${id}`, entityId: id, - title, + title: t(`overview.sections.${id}.title`), subtitle: 'Settings', - body: description, + body: t(`overview.sections.${id}.description`), keywords: `preferences configuration ${id}`, route: `/settings/${id}`, }), diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx index 378e919..c3abe7c 100644 --- a/src/views/Settings/SettingsOverview.jsx +++ b/src/views/Settings/SettingsOverview.jsx @@ -1,22 +1,4 @@ -import { - AccountCircle, - Api, - BugReport, - ChevronRight, - Circle, - Code, - FamilyRestroom, - Feedback, - Language, - Notifications, - Palette, - Person, - Security, - Settings, - Star, - Storage, - ViewSidebar, -} from '@mui/icons-material' +import { BugReport, ChevronRight, Feedback, Star } from '@mui/icons-material' import { Avatar, Box, @@ -37,6 +19,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' +import { SETTINGS_SECTIONS } from '../../constants/settingsSections' import { useUserProfile } from '../../queries/UserQueries' import { isPlusAccount } from '../../utils/Helpers' import { isParentUser } from '../../utils/UserHelpers' @@ -51,85 +34,13 @@ const SettingsOverview = () => { const [bugReportOpen, setBugReportOpen] = useState(false) const settingsCards = [ - { - id: 'profile', - title: t('overview.sections.profile.title'), - description: t('overview.sections.profile.description'), - icon: , - }, - { - id: 'circle', - title: t('overview.sections.circle.title'), - description: t('overview.sections.circle.description'), - icon: , - }, - { - id: 'account', - title: t('overview.sections.account.title'), - description: t('overview.sections.account.description'), - icon: , - }, - { - id: 'subaccounts', - title: t('overview.sections.subaccounts.title'), - description: t('overview.sections.subaccounts.description'), - icon: , - }, - { - id: 'notifications', - title: t('overview.sections.notifications.title'), - description: t('overview.sections.notifications.description'), - icon: , - }, - { - id: 'mfa', - title: t('overview.sections.mfa.title'), - description: t('overview.sections.mfa.description'), - icon: , - }, - { - id: 'apitokens', - title: t('overview.sections.apitokens.title'), - description: t('overview.sections.apitokens.description'), - icon: , - }, - { - id: 'storage', - title: t('overview.sections.storage.title'), - description: t('overview.sections.storage.description'), - icon: , - }, - { - id: 'sidepanel', - title: t('overview.sections.sidepanel.title'), - description: t('overview.sections.sidepanel.description'), - icon: , - }, - { - id: 'theme', - title: t('overview.sections.theme.title'), - description: t('overview.sections.theme.description'), - icon: , - }, - { - id: 'localization', - title: t('overview.sections.localization.title'), - description: t('overview.sections.localization.description'), - icon: , - isBeta: true, - }, - { - id: 'advanced', - title: t('overview.sections.advanced.title'), - description: t('overview.sections.advanced.description'), - icon: , - }, - { - id: 'developer', - title: t('overview.sections.developer.title'), - description: t('overview.sections.developer.description'), - icon: , - }, + ...SETTINGS_SECTIONS.map(({ icon: Icon, id, isBeta }) => ({ + id, + title: t(`overview.sections.${id}.title`), + description: t(`overview.sections.${id}.description`), + icon: , + isBeta, + })), { id: 'feedback', title: t('overview.sections.feedback.title'), @@ -154,23 +65,19 @@ const SettingsOverview = () => { navigate(`/settings/${setting.id}`) } + const parentOnlyIds = SETTINGS_SECTIONS.filter( + section => section.parentOnly, + ).map(section => section.id) + // Filter settings based on user type const getAvailableSettings = () => { - const parentOnlySettings = [ - 'children', - 'mfa', - 'apitokens', - 'circle', - 'account', - ] - if (isParentUser(userProfile)) { // Parent users can access all settings return settingsCards } else { // Child users can only access basic settings return settingsCards.filter( - setting => !parentOnlySettings.includes(setting.id), + setting => !parentOnlyIds.includes(setting.id), ) } } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 255fb7d..27074fe 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,5 +1,14 @@ -import { Add } from '@mui/icons-material' -import { Box, Button, Typography } from '@mui/joy' +import { Add, KeyboardArrowDown } from '@mui/icons-material' +import { + Box, + Button, + Dropdown, + ListItemDecorator, + Menu, + MenuButton, + MenuItem, + Typography, +} from '@mui/joy' import { useMediaQuery } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import * as chrono from 'chrono-node' @@ -9,6 +18,7 @@ import { flushSync } from 'react-dom' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import ModalActions from '../../components/common/ModalActions' +import { Z_INDEX } from '../../constants/zIndex' import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { useFileUpload } from '../../hooks/useFileUpload' import { useResponsiveModal } from '../../hooks/useResponsiveModal' @@ -20,6 +30,7 @@ import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors' import { CreateLabel } from '../../utils/Fetcher' import { imageSourceToFile } from '../../utils/FileConvert' import { isPlusAccount } from '../../utils/Helpers' +import { getIconComponent } from '../../utils/ProjectIcons' import { generateUUID } from '../../utils/UUID' import { useLabels } from '../Labels/LabelQueries' import { useProjects } from '../Projects/ProjectQueries' @@ -122,6 +133,13 @@ const getInitialProject = () => { return 'default' } +const DEFAULT_PROJECT = { + id: 'default', + name: 'Default Project', + color: '#9CA3AF', + icon: 'FolderOpen', +} + const PRIORITY_COLORS = { 0: TASK_COLOR.NO_PRIORITY, 1: TASK_COLOR.PRIORITY_1, @@ -175,7 +193,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: circleMembers, isLoading: isCircleMembersLoading } = useCircleMembers() - const { isLoading: isProjectsLoading } = useProjects() + const { data: projects, isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() const queryClient = useQueryClient() @@ -293,6 +311,17 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { const [useCustomTime, setUseCustomTime] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [projectId, setProjectId] = useState(getInitialProject) + const selectedProject = useMemo( + () => + (projectId !== 'default' && + projects?.find(project => project.id === projectId)) || + DEFAULT_PROJECT, + [projects, projectId], + ) + const SelectedProjectIcon = useMemo( + () => getIconComponent(selectedProject.icon), + [selectedProject], + ) const [attachments, setAttachments] = useState([]) const [draftId, setDraftId] = useState(() => generateUUID()) @@ -1117,6 +1146,50 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { title='Create new task' footer={ + {!showScan && !showVoice && projects?.length >= 1 && ( + + + } + endDecorator={} + sx={{ + mr: 'auto', + color: 'text.secondary', + fontWeight: 'normal', + }} + > + {selectedProject.name} + + + {[DEFAULT_PROJECT, ...projects].map(project => { + const ProjectIcon = getIconComponent(project.icon) + return ( + setProjectId(project.id)} + > + + + + {project.name} + + ) + })} + + + )} ) -export const AuthDivider = ({ children = 'or' }) => ( - - - {children} - - -) +export const AuthDivider = ({ children }) => { + const { t } = useTranslation('auth') + + return ( + + + {children ?? t('or')} + + + ) +} export const LegalLinks = () => ( { + const { t } = useTranslation('auth') const { refetch: refetchUserProfile } = useUserProfile() const Navigate = useNavigate() const hasCalledHandleOAuth2 = useRef(false) - const [message, setMessage] = useState('Signing you in') - const [subMessage, setSubMessage] = useState('This will only take a moment.') + const [message, setMessage] = useState(t('authenticating.signingIn')) + const [subMessage, setSubMessage] = useState(t('authenticating.signingInSub')) const [status, setStatus] = useState('pending') const [mfaModalOpen, setMfaModalOpen] = useState(false) const [mfaSessionToken, setMfaSessionToken] = useState('') @@ -31,8 +33,8 @@ const AuthenticationLoading = () => { // suppress a genuine session expiry later on. handleOAuth2().finally(endOAuthExchange) } else if (provider !== 'oauth2') { - setMessage('Unknown sign-in provider') - setSubMessage('Please contact support.') + setMessage(t('authenticating.unknownProvider')) + setSubMessage(t('authenticating.contactSupport')) setStatus('error') } return endOAuthExchange @@ -71,8 +73,8 @@ const AuthenticationLoading = () => { const handleMFAClose = () => { setMfaModalOpen(false) setMfaSessionToken('') - setMessage('Sign-in failed') - setSubMessage('Two-factor authentication was cancelled.') + setMessage(t('authenticating.signInFailed')) + setSubMessage(t('authenticating.mfaCancelled')) setStatus('error') } @@ -85,8 +87,8 @@ const AuthenticationLoading = () => { const storedState = localStorage.getItem('authState') if (returnedState !== storedState) { - setMessage('Sign-in failed') - setSubMessage('The sign-in request could not be verified.') + setMessage(t('authenticating.signInFailed')) + setSubMessage(t('authenticating.requestNotVerified')) setStatus('error') return } @@ -112,8 +114,8 @@ const AuthenticationLoading = () => { if (!response.ok) { console.error('Authentication failed') - setMessage('Sign-in failed') - setSubMessage('Please try again.') + setMessage(t('authenticating.signInFailed')) + setSubMessage(t('authenticating.tryAgain')) setStatus('error') return } @@ -122,22 +124,22 @@ const AuthenticationLoading = () => { if (data.mfaRequired) { if (!data.sessionToken) { - setMessage('Sign-in failed') - setSubMessage('The MFA session is missing. Please try again.') + setMessage(t('authenticating.signInFailed')) + setSubMessage(t('authenticating.mfaSessionMissing')) setStatus('error') return } setMfaSessionToken(data.sessionToken) setMfaModalOpen(true) - setMessage('Two-factor authentication') - setSubMessage('Verify your login to continue.') + setMessage(t('authenticating.twoFactor')) + setSubMessage(t('authenticating.verifyToContinue')) return } if (!data.token && !data.access_token) { - setMessage('Sign-in failed') - setSubMessage('No valid authentication token was returned.') + setMessage(t('authenticating.signInFailed')) + setSubMessage(t('authenticating.noToken')) setStatus('error') return } @@ -158,8 +160,8 @@ const AuthenticationLoading = () => { } } catch (error) { console.error('Authentication request failed', error) - setMessage('Sign-in failed') - setSubMessage('Please try again.') + setMessage(t('authenticating.signInFailed')) + setSubMessage(t('authenticating.tryAgain')) setStatus('error') } } @@ -188,7 +190,7 @@ const AuthenticationLoading = () => { fullWidth sx={authButtonSx} > - Back to sign in + {t('authenticating.backToSignIn')} )} @@ -199,8 +201,8 @@ const AuthenticationLoading = () => { sessionToken={mfaSessionToken} onSuccess={handleMFASuccess} onError={() => { - setMessage('Sign-in failed') - setSubMessage('Two-factor authentication failed. Please try again.') + setMessage(t('authenticating.signInFailed')) + setSubMessage(t('authenticating.mfaFailed')) }} /> diff --git a/src/views/Authorization/ForgotPasswordView.jsx b/src/views/Authorization/ForgotPasswordView.jsx index 6bd73a9..de6e0e8 100644 --- a/src/views/Authorization/ForgotPasswordView.jsx +++ b/src/views/Authorization/ForgotPasswordView.jsx @@ -1,6 +1,7 @@ import MarkEmailReadOutlined from '@mui/icons-material/MarkEmailReadOutlined' import { Box, Button, Link, Typography } from '@mui/joy' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { useNotification } from '../../service/NotificationProvider' import { ResetPassword } from '../../utils/Fetcher' @@ -12,6 +13,7 @@ const isInvalidEmail = email => !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email) const ForgotPasswordView = () => { + const { t } = useTranslation('auth') const navigate = useNavigate() const [resetStatusOk, setResetStatusOk] = useState(null) const [email, setEmail] = useState('') @@ -40,21 +42,21 @@ const ForgotPasswordView = () => { setResetStatusOk(true) showNotification({ type: 'success', - title: 'Reset Email Sent', - message: 'Check your email for password reset instructions', + title: t('resetEmailSent'), + message: t('resetEmailSentMsg'), }) } else { setResetStatusOk(false) showError({ - title: 'Reset Failed', - message: 'Failed to send reset email, please try again later', + title: t('resetFailed'), + message: t('resetFailedMsg'), }) } } catch (error) { setResetStatusOk(false) showError({ - title: 'Reset Failed', - message: 'Failed to send reset email, please try again later', + title: t('resetFailed'), + message: t('resetFailedMsg'), }) } finally { setIsSubmitting(false) @@ -79,7 +81,7 @@ const ForgotPasswordView = () => { if (resetStatusOk !== null) { return ( } logoSize={0} @@ -101,7 +103,7 @@ const ForgotPasswordView = () => { sx={authButtonSx} onClick={() => navigate('/login')} > - Back to sign in + {t('backToSignIn')} @@ -110,7 +112,7 @@ const ForgotPasswordView = () => { return ( } logoSize={0} @@ -126,7 +128,7 @@ const ForgotPasswordView = () => { name='email' type='email' autoComplete='email' - placeholder='you@example.com' + placeholder={t('emailPlaceholder')} autoFocus value={email} error={emailError} @@ -152,7 +154,7 @@ const ForgotPasswordView = () => { underline='hover' onClick={() => navigate('/login')} > - Back to sign in + {t('backToSignIn')} diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx index 6cd1d3d..3087c3d 100644 --- a/src/views/Authorization/LoginSettings.jsx +++ b/src/views/Authorization/LoginSettings.jsx @@ -12,10 +12,12 @@ import { offlineDB } from '../../utils/OfflineDB' import { AuthSubmitButton, AuthTextField } from './AuthFields' import AuthShell from './AuthShell' import { authButtonSx } from './authStyles' +import { useTranslation } from 'react-i18next' const CONNECTION_TIMEOUT_MS = 8000 const LoginSettings = () => { + const { t } = useTranslation('auth') const Navigate = useNavigate() const { refetch: refetchResource } = useResource() const [serverURL, setServerURL] = React.useState('') @@ -105,7 +107,7 @@ const LoginSettings = () => { return { ok: false, message: - 'Hostname could not be resolved. Check the URL for typos or verify DNS.', + t('server.dnsFailed'), } } return { @@ -118,7 +120,7 @@ const LoginSettings = () => { return { ok: false, message: - 'Unable to reach the server. Check the URL, port, and network connection.', + t('server.unreachable'), } } @@ -126,7 +128,7 @@ const LoginSettings = () => { return { ok: false, message: - 'Unable to reach the server. Check the URL, port, and network connection.', + t('server.unreachable'), } } } @@ -144,7 +146,7 @@ const LoginSettings = () => { if (!isValidURL(trimmedURL)) { setStatus('error') setErrorMessage( - 'Invalid URL format. Include the protocol (http:// or https://) and port if needed.', + t('server.invalidUrl'), ) return } @@ -187,7 +189,7 @@ const LoginSettings = () => { return ( { sx={{ display: 'flex', flexDirection: 'column' }} > { startDecorator={} sx={{ mt: 2, borderRadius: '12px' }} > - Connected. Taking you to sign in... + {t('serverConnected')} )} @@ -290,7 +292,7 @@ const LoginSettings = () => { level='body-xs' sx={{ mt: 2.5, textAlign: 'center', color: 'text.secondary' }} > - Changing the server clears locally cached data on this device. + {t('serverChangeWarning')} ) diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index 32e7a37..ab5e492 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -10,6 +10,7 @@ import { Avatar, Box, Button, IconButton, Link, Typography } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' import Cookies from 'js-cookie' import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { LoginSocialGoogle } from 'reactjs-social-login' @@ -84,6 +85,7 @@ const SegmentedControl = ({ value, onChange, options }) => ( ) const LoginView = () => { + const { t } = useTranslation('auth') // Use React Query client directly to invalidate the user profile query const queryClient = useQueryClient() const { data: userProfile } = useUserProfile() @@ -158,23 +160,23 @@ const LoginView = () => { if (loginType === 'sub') { if (!parentUsername.trim()) { showError({ - title: 'Validation Error', - message: 'Primary username is required for sub account login', + title: t('validationError'), + message: t('primaryUsernameRequired'), }) return } if (!childName.trim()) { showError({ - title: 'Validation Error', - message: 'Sub account name is required for sub account login', + title: t('validationError'), + message: t('subNameRequired'), }) return } } else { if (!username.trim()) { showError({ - title: 'Validation Error', - message: 'Username is required', + title: t('validationError'), + message: t('usernameRequired'), }) return } @@ -182,8 +184,8 @@ const LoginView = () => { if (!password) { showError({ - title: 'Validation Error', - message: 'Password is required', + title: t('validationError'), + message: t('passwordRequired'), }) return } @@ -200,7 +202,7 @@ const LoginView = () => { result = await authLogin({ username: actualUsername, password }) } catch (error) { showError({ - title: 'Login Failed', + title: t('loginFailed'), message: error?.message || 'An error occurred, please try again', }) return @@ -227,8 +229,8 @@ const LoginView = () => { } } else { showError({ - title: 'Login Failed', - message: result.error || 'An error occurred, please try again', + title: t('loginFailed'), + message: result.error || t('genericError'), }) } } @@ -298,15 +300,15 @@ const LoginView = () => { } else { const providerName = provider === 'apple' ? 'Apple' : 'Google' showError({ - title: `${providerName} Login Failed`, - message: `Couldn't log in with ${providerName}, please try again`, + title: t('providerLoginFailed', { provider: providerName }), + message: t('providerLoginFailedMsg', { provider: providerName }), }) } } catch (error) { const providerName = provider === 'apple' ? 'Apple' : 'Google' showError({ - title: `${providerName} Login Error`, - message: 'Network error occurred, please try again', + title: t('providerLoginError', { provider: providerName }), + message: t('networkError'), }) } } @@ -350,7 +352,7 @@ const LoginView = () => { const handleMFAError = errorMessage => { showError({ - title: 'Two-Factor Authentication Failed', + title: t('mfaFailed'), message: errorMessage, }) } @@ -398,8 +400,8 @@ const LoginView = () => { } catch (error) { console.error('Failed to open OAuth browser:', error) showError({ - title: 'OAuth Error', - message: 'Failed to open authentication browser', + title: t('oauthError'), + message: t('oauthBrowserFailed'), }) } } else { @@ -439,7 +441,7 @@ const LoginView = () => { Navigate('/login/settings')} > @@ -465,7 +467,7 @@ const LoginView = () => { {displayName} {getUserDisplayInfo(userProfile).userType === 'child' && ( - Sub Account + {t('subAccount')} )} @@ -486,7 +488,7 @@ const LoginView = () => { sx={authButtonSx} onClick={() => apiClient.handleLogout()} > - Use a different account + {t('useDifferentAccount')} ) : ( @@ -499,19 +501,19 @@ const LoginView = () => { value={loginType} onChange={handleLoginModeChange} options={[ - { value: 'primary', label: 'Primary Account' }, - { value: 'sub', label: 'Sub Account' }, + { value: 'primary', label: t('primaryAccount') }, + { value: 'sub', label: t('subAccount') }, ]} /> {loginType === 'primary' ? ( setUsername(e.target.value)} @@ -519,20 +521,20 @@ const LoginView = () => { ) : ( <> setParentUsername(e.target.value)} /> setChildName(e.target.value)} /> @@ -544,7 +546,8 @@ const LoginView = () => { id='password' name='password' autoComplete='current-password' - placeholder='Enter your password' + label={t('passwordLabel')} + placeholder={t('loginPasswordPlaceholder')} value={password} onChange={e => setPassword(e.target.value)} /> @@ -556,7 +559,7 @@ const LoginView = () => { underline='hover' onClick={handleForgotPassword} > - Forgot password? + {t('forgotPassword')} @@ -568,7 +571,7 @@ const LoginView = () => { )} - {hasSocialOptions && or continue with} + {hasSocialOptions && {t('orContinueWith')}} {showSocialLogin && !Capacitor.isNativePlatform() && ( @@ -584,7 +587,7 @@ const LoginView = () => { }} onReject={() => { showError({ - title: 'Google Login Failed', + title: t('googleLoginFailed'), message: "Couldn't log in with Google, please try again", }) }} @@ -608,7 +611,7 @@ const LoginView = () => { } catch (error) { console.error('Google login error:', error) showError({ - title: 'Google Login Failed', + title: t('googleLoginFailed'), message: `Couldn't log in with Google, please try again${ error?.message ? `: ${error.message}` : '' }`, @@ -637,7 +640,7 @@ const LoginView = () => { .catch(error => { console.error('Apple login error:', error) showError({ - title: 'Apple Login Failed', + title: t('appleLoginFailed'), message: "Couldn't log in with Apple, please try again", }) }) @@ -670,7 +673,7 @@ const LoginView = () => { underline='hover' onClick={() => Navigate('/signup')} > - Create one + {t('createOne')} )} diff --git a/src/views/Authorization/MFAVerificationModal.jsx b/src/views/Authorization/MFAVerificationModal.jsx index 8261301..6bf4011 100644 --- a/src/views/Authorization/MFAVerificationModal.jsx +++ b/src/views/Authorization/MFAVerificationModal.jsx @@ -5,6 +5,7 @@ import ModalActions from '../../components/common/ModalActions' import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { VerifyMFA } from '../../utils/Fetcher' import { authInputSx } from './authStyles' +import { useTranslation } from 'react-i18next' const MFAVerificationModal = ({ open, @@ -13,6 +14,7 @@ const MFAVerificationModal = ({ onSuccess, onError, }) => { + const { t } = useTranslation('auth') const [verificationCode, setVerificationCode] = useState('') const [isBackupCode, setIsBackupCode] = useState(false) const [loading, setLoading] = useState(false) @@ -21,7 +23,7 @@ const MFAVerificationModal = ({ const handleVerify = async () => { if (!verificationCode.trim()) { - setError('Please enter a verification code') + setError(t('mfaModal.codeRequired')) return } @@ -37,14 +39,14 @@ const MFAVerificationModal = ({ } else { const errorData = await response.json() const message = - errorData.message || 'Invalid verification code. Please try again.' + errorData.message || t('mfaModal.invalidCode') setError(message) onError?.(message) } } catch (error) { // A wrong code is shown inline; a failed request is escalated to the // caller so it can surface a toast instead of looking like a bad code. - const message = 'Failed to verify code. Please try again.' + const message = t('mfaModal.verifyFailed') setError(message) onError?.(message) console.error('MFA verification error:', error) @@ -73,23 +75,23 @@ const MFAVerificationModal = ({ open={open} onClose={handleClose} size='md' - title='Two-factor authentication' + title={t('mfaModal.title')} description={ isBackupCode - ? 'Enter one of the backup codes you saved when setting up two-factor authentication.' - : 'Enter the 6-digit code from your authenticator app.' + ? t('mfaModal.backupHint') + : t('mfaModal.codeHint') } closeOnBackdrop={!loading} closeOnEscape={!loading} footer={ - {isBackupCode ? 'Backup code' : 'Verification code'} + {isBackupCode ? t('mfaModal.backupLabel') : t('mfaModal.codeLabel')} setVerificationCode(e.target.value)} onKeyDown={handleKeyDown} @@ -157,8 +159,8 @@ const MFAVerificationModal = ({ }} > {isBackupCode - ? 'Use authenticator app instead' - : 'Use a backup code instead'} + ? t('mfaModal.useAuthenticator') + : t('mfaModal.useBackup')} @@ -167,7 +169,7 @@ const MFAVerificationModal = ({ level='body-xs' sx={{ textAlign: 'center', color: 'text.secondary' }} > - Each backup code can only be used once. + {t('mfaModal.backupOnce')} )} diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx index 8600ce4..698d28a 100644 --- a/src/views/Authorization/Signup.jsx +++ b/src/views/Authorization/Signup.jsx @@ -1,6 +1,7 @@ import { Box, Link, Typography } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' import React from 'react' +import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { useAuth } from '../../hooks/useAuth.jsx' @@ -16,6 +17,7 @@ import { import AuthShell from './AuthShell' const SignupView = () => { + const { t } = useTranslation('auth') const [username, setUsername] = React.useState('') const [password, setPassword] = React.useState('') const Navigate = useNavigate() @@ -38,7 +40,7 @@ const SignupView = () => { showError({ title: 'Almost there', message: - 'Your account was created, but signing in failed. Please sign in.', + t('signupSignInFailed'), }) Navigate('/login') return @@ -72,36 +74,36 @@ const SignupView = () => { let isValid = true if (!username.trim()) { - setUsernameError('Username is required') + setUsernameError(t('usernameRequired')) isValid = false } if (username.length < 4) { - setUsernameError('Username must be at least 4 characters') + setUsernameError(t('usernameMinLength')) isValid = false } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - setEmailError('Invalid email address') + setEmailError(t('invalidEmail')) isValid = false } if (password.length < 8) { - setPasswordError('Password must be between 8 and 64 characters') + setPasswordError(t('passwordLength')) isValid = false } if (password.length > 64) { - setPasswordError('Password must be between 8 and 64 characters') + setPasswordError(t('passwordLength')) isValid = false } if (!displayName.trim()) { - setDisplayNameError('Display name is required') + setDisplayNameError(t('displayNameRequired')) isValid = false } // display name should only contain letters and spaces and numbers: if (!/^[a-zA-Z0-9 ]+$/.test(displayName)) { - setDisplayNameError('Display name can only contain letters and numbers') + setDisplayNameError(t('displayNameChars')) isValid = false } @@ -127,14 +129,14 @@ const SignupView = () => { handleLogin(username, password) } else if (response.status === 403) { showError({ - title: 'Signup Failed', - message: 'Signup disabled, please contact admin', + title: t('signupFailed'), + message: t('signupDisabled'), }) } else { console.log('Signup failed') response.json().then(res => { showError({ - title: 'Signup Failed', + title: t('signupFailed'), message: res.error || 'An error occurred during signup', }) }) @@ -145,7 +147,7 @@ const SignupView = () => { return ( { sx={{ display: 'flex', flexDirection: 'column', gap: 2 }} > { /> { @@ -189,12 +191,12 @@ const SignupView = () => { /> { @@ -207,10 +209,11 @@ const SignupView = () => { id='password' name='password' autoComplete='new-password' - placeholder='At least 8 characters' + label={t('passwordLabel')} + placeholder={t('signupPasswordPlaceholder')} value={password} error={passwordError} - helper='Use 8 to 64 characters.' + helper={t('signupPasswordHelper')} onChange={e => { setPasswordError(null) setPassword(e.target.value) @@ -218,14 +221,14 @@ const SignupView = () => { /> - Create account + {t('createAccountButton')} - By creating an account you agree to our Terms of Service and Privacy + {t('termsShort')} Policy. diff --git a/src/views/Authorization/UpdatePasswordView.jsx b/src/views/Authorization/UpdatePasswordView.jsx index 0122e7c..bb672ec 100644 --- a/src/views/Authorization/UpdatePasswordView.jsx +++ b/src/views/Authorization/UpdatePasswordView.jsx @@ -1,5 +1,6 @@ import { Box, Button } from '@mui/joy' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' import { useNotification } from '../../service/NotificationProvider' @@ -9,6 +10,7 @@ import AuthShell from './AuthShell' import { authButtonSx } from './authStyles' const UpdatePasswordView = () => { + const { t } = useTranslation('auth') const navigate = useNavigate() const [password, setPassword] = useState('') const [passwordConfirm, setPasswordConfirm] = useState('') @@ -67,23 +69,22 @@ const UpdatePasswordView = () => { if (response.ok) { showNotification({ type: 'success', - title: 'Password Updated', - message: - 'Your password has been updated successfully. Redirecting to login...', + title: t('passwordUpdated'), + message: t('passwordUpdatedMsg'), }) setTimeout(() => { navigate('/login') }, 3000) } else { showError({ - title: 'Password Update Failed', - message: 'Failed to update password, please try again later', + title: t('passwordUpdateFailed'), + message: t('passwordUpdateFailedMsg'), }) } } catch (error) { showError({ - title: 'Password Update Failed', - message: 'Failed to update password, please try again later', + title: t('passwordUpdateFailed'), + message: t('passwordUpdateFailedMsg'), }) } finally { setIsSubmitting(false) @@ -93,7 +94,7 @@ const UpdatePasswordView = () => { if (!verificationCode) { return ( } showLogo @@ -106,7 +107,7 @@ const UpdatePasswordView = () => { sx={authButtonSx} onClick={() => navigate('/forgot-password')} > - Request a new link + {t('requestNewLink')} @@ -125,7 +126,7 @@ const UpdatePasswordView = () => { return ( } // Reached from an emailed link, usually in a browser: an unbranded page @@ -138,7 +139,7 @@ const UpdatePasswordView = () => { sx={{ display: 'flex', flexDirection: 'column', gap: 2 }} > { /> - Save password + {t('savePassword')} From bdac10ac5779cdca5571c728f455c5125a6400b5 Mon Sep 17 00:00:00 2001 From: everysingletear Date: Thu, 13 Aug 2026 10:16:14 +0800 Subject: [PATCH 3/4] i18n: extract shared strings into the existing `common` namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #145. Fourteen files whose user-facing strings are generic enough to belong in `common` — loading and empty states, the confirmation modal, the shared input modals (text/date/user/attachment viewer), the mobile nav bar, the autocomplete input, the error screen and the file-upload error paths. Extends the namespace that already exists, so `src/i18n/config.js` is untouched and this cannot collide with any other extraction PR over the `ns:` array. 28 keys added to `public/locales/en/common.json`. English only — no translations, no behaviour change. Every t() value is checked against this branch's base: the string must appear character-for-character in the code it replaces (36 call sites). One value is matched loosely and worth naming: `errorScreen.hideDetails`. The base renders `{showDetails ? 'Hide' : 'Show'} error details`, so neither full phrase exists contiguously in the source — only one branch of the ternary can. Both keys hold exactly what each branch renders. The sentence is kept whole rather than split around the ternary, because a split sentence cannot be reordered by a translator. --- public/locales/en/common.json | 32 +++++++++++++++++++ src/components/animations/LoadingScreen.jsx | 9 ++++-- .../common/filter/ActiveFilterChips.jsx | 5 ++- src/hooks/useConfirmationModal.js | 6 ++-- src/hooks/useFileUpload.js | 16 ++++++---- src/views/Error.jsx | 21 ++++++------ src/views/Home.jsx | 4 ++- .../Modals/Inputs/AttachmentViewerModal.jsx | 6 ++-- src/views/Modals/Inputs/DateModal.jsx | 6 ++-- src/views/Modals/Inputs/TextModal.jsx | 4 ++- src/views/Modals/Inputs/UserModal.jsx | 4 ++- src/views/SummaryCard.jsx | 4 ++- src/views/components/AutocompleteInput.jsx | 4 ++- src/views/components/Loading.jsx | 18 +++++------ src/views/components/NavBarMobile.jsx | 6 ++-- 15 files changed, 102 insertions(+), 43 deletions(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 1a1056e..15f09ad 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -114,5 +114,37 @@ "subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.", "open": "Open the issue" } + }, + "done": "Done", + "clearAll": "Clear all", + "upload": { + "quotaTitle": "Storage Quota Exceeded", + "quotaMessage": "You have exceeded your quota for uploading files.", + "tooLargeTitle": "File Too Large", + "tooLargeMessage": "The file you are trying to upload is too large.", + "deniedTitle": "Permission Denied", + "deniedMessage": "You do not have permission to upload files." + }, + "getStarted": "Get Started!", + "imageLoadFailed": "Failed to load image.", + "typeHere": "Type in here…", + "summaryOfChores": "This is a summary of your chores", + "loadingOffline": "You are offline", + "loadingOfflineSub": "This not available while offline. Please check your internet connection and try again.", + "loadingSlow": "This is taking longer than usual. There might be an issue.", + "navigateBack": "Navigate Back", + "bottomNav": "Bottom Navigation", + "profile": "Profile", + "autocompletePlaceholder": "Type here...", + "errorScreen": { + "title": "Something went wrong", + "fallback": "An unexpected error occurred. Try reloading — it usually fixes it.", + "tryAgain": "Try again", + "home": "Home", + "login": "Login", + "hideDetails": "Hide error details", + "showDetails": "Show error details", + "copyToClipboard": "Copy to clipboard", + "copied": "Error details copied to clipboard" } } diff --git a/src/components/animations/LoadingScreen.jsx b/src/components/animations/LoadingScreen.jsx index 6ee18c9..743e7e2 100644 --- a/src/components/animations/LoadingScreen.jsx +++ b/src/components/animations/LoadingScreen.jsx @@ -63,11 +63,14 @@ const LogoContainer = styled(Box)({ marginBottom: '24px', }) +import { useTranslation } from 'react-i18next' + const LoadingScreen = ({ - message = 'Loading...', + message = null, showLogo = true, size = 'lg', }) => { + const { t } = useTranslation('common') return ( @@ -82,7 +85,7 @@ const LoadingScreen = ({ mb: 1, }} > - Done + {t('done')} tick @@ -96,7 +99,7 @@ const LoadingScreen = ({ }} /> - {message} + {message ?? t('loading')} ) diff --git a/src/components/common/filter/ActiveFilterChips.jsx b/src/components/common/filter/ActiveFilterChips.jsx index 7978629..faef0df 100644 --- a/src/components/common/filter/ActiveFilterChips.jsx +++ b/src/components/common/filter/ActiveFilterChips.jsx @@ -1,6 +1,8 @@ import { Add, Close } from '@mui/icons-material' import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy' +import { useTranslation } from 'react-i18next' + const ActiveFilterChips = ({ chipSize = 'md', chipSx, @@ -18,6 +20,7 @@ const ActiveFilterChips = ({ showAddChip = false, totalCount, }) => { + const { t } = useTranslation('common') if (!chips.length) { return null } @@ -161,7 +164,7 @@ const ActiveFilterChips = ({ ...clearButtonSx, }} > - Clear all + {t('clearAll')} )} diff --git a/src/hooks/useConfirmationModal.js b/src/hooks/useConfirmationModal.js index 674ef47..516f0ca 100644 --- a/src/hooks/useConfirmationModal.js +++ b/src/hooks/useConfirmationModal.js @@ -1,14 +1,16 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' const useConfirmationModal = () => { + const { t } = useTranslation('common') const [confirmModalConfig, setConfirmModalConfig] = useState({}) const showConfirmation = ( message, title, onConfirm, - confirmText = 'Confirm', - cancelText = 'Cancel', + confirmText = t('confirm'), + cancelText = t('cancel'), color = 'primary', ) => { setConfirmModalConfig({ diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js index ec84b7e..f4430b4 100644 --- a/src/hooks/useFileUpload.js +++ b/src/hooks/useFileUpload.js @@ -5,12 +5,14 @@ import { useUserProfile } from '../queries/UserQueries' import { useNotification } from '../service/NotificationProvider' import { apiClient } from '../utils/ApiClient' import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers' +import { useTranslation } from 'react-i18next' export const useFileUpload = ({ draftId, entityId, entityType = 'chore_attachment', } = {}) => { + const { t } = useTranslation('common') const { showError } = useNotification() const { data: userProfile } = useUserProfile() @@ -58,14 +60,14 @@ export const useFileUpload = ({ if (response.status === 507) { showError({ - title: 'Storage Quota Exceeded', - message: 'You have exceeded your quota for uploading files.', + title: t('upload.quotaTitle'), + message: t('upload.quotaMessage'), }) return null } else if (response.status === 413) { showError({ - title: 'File Too Large', - message: 'The file you are trying to upload is too large.', + title: t('upload.tooLargeTitle'), + message: t('upload.tooLargeMessage'), }) return null } else if (response.status === 403 && !isPlusAccount(userProfile)) { @@ -76,8 +78,8 @@ export const useFileUpload = ({ return null } else if (response.status === 403) { showError({ - title: 'Permission Denied', - message: 'You do not have permission to upload files.', + title: t('upload.deniedTitle'), + message: t('upload.deniedMessage'), }) return null } else if (!response.ok) { @@ -105,7 +107,7 @@ export const useFileUpload = ({ return null } }, - [entityType, entityId, draftId, showError, userProfile], + [entityType, entityId, draftId, showError, userProfile, t], ) return { uploadFile, isPlus: isPlusAccount(userProfile) } diff --git a/src/views/Error.jsx b/src/views/Error.jsx index f06a2b1..993b855 100644 --- a/src/views/Error.jsx +++ b/src/views/Error.jsx @@ -18,6 +18,7 @@ import { formatErrorReport, } from '../service/ErrorReportService' import ErrorReportModal from './Modals/ErrorReportModal' +import { useTranslation } from 'react-i18next' const getErrorKind = error => { if (!error) @@ -55,6 +56,7 @@ const safeMessage = error => { } const Error = () => { + const { t } = useTranslation('common') const error = useRouteError() const [showDetails, setShowDetails] = useState(false) const [copied, setCopied] = useState(false) @@ -192,7 +194,7 @@ const Error = () => { textAlign='center' sx={{ mb: 1.5 }} > - Something went wrong + {t('errorScreen.title')} {/* Error message */} @@ -207,8 +209,7 @@ const Error = () => { wordBreak: 'break-word', }} > - {message ?? - 'An unexpected error occurred. Try reloading — it usually fixes it.'} + {message ?? t('errorScreen.fallback')} {/* Primary CTA */} @@ -220,7 +221,7 @@ const Error = () => { onClick={() => window.location.reload()} sx={{ width: '100%', mb: 1.5 }} > - Try again + {t('errorScreen.tryAgain')} {/* Reporting is one tap from the failure, where the context is still @@ -246,7 +247,7 @@ const Error = () => { size='lg' startDecorator={} > - Home + {t('errorScreen.home')} @@ -293,7 +294,9 @@ const Error = () => { } sx={{ mb: 1 }} > - {showDetails ? 'Hide' : 'Show'} error details + {showDetails + ? t('errorScreen.hideDetails') + : t('errorScreen.showDetails')} {showDetails && ( @@ -312,7 +315,7 @@ const Error = () => { color='neutral' onClick={handleCopy} sx={{ position: 'absolute', top: 8, right: 8 }} - title='Copy to clipboard' + title={t('errorScreen.copyToClipboard')} > @@ -347,7 +350,7 @@ const Error = () => { anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} size='sm' > - Error details copied to clipboard + {t('errorScreen.copied')} ) diff --git a/src/views/Home.jsx b/src/views/Home.jsx index 894e7a7..5600c1b 100644 --- a/src/views/Home.jsx +++ b/src/views/Home.jsx @@ -1,10 +1,12 @@ import { Box, Button, Container, Typography } from '@mui/joy' +import { useTranslation } from 'react-i18next' import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useState } from 'react' import Logo from '../Logo' const Home = () => { + const { t } = useTranslation('common') const Navigate = useNavigate() const getCurrentUser = () => { return JSON.parse(localStorage.getItem('user')) @@ -36,7 +38,7 @@ const Home = () => { Navigate('/chores') }} > - Get Started! + {t('getStarted')} diff --git a/src/views/Modals/Inputs/AttachmentViewerModal.jsx b/src/views/Modals/Inputs/AttachmentViewerModal.jsx index c0eefc2..1da2fb7 100644 --- a/src/views/Modals/Inputs/AttachmentViewerModal.jsx +++ b/src/views/Modals/Inputs/AttachmentViewerModal.jsx @@ -1,4 +1,5 @@ import { Browser } from '@capacitor/browser' +import { useTranslation } from 'react-i18next' import { Capacitor } from '@capacitor/core' import { Download } from '@mui/icons-material' import { Box, CircularProgress, Typography } from '@mui/joy' @@ -29,6 +30,7 @@ const downloadUrl = (url, fileName) => { } function AttachmentViewerModal({ config }) { + const { t } = useTranslation('common') const { ResponsiveModal } = useResponsiveModal() const [imgLoaded, setImgLoaded] = useState(false) const [imgError, setImgError] = useState(false) @@ -49,7 +51,7 @@ function AttachmentViewerModal({ config }) { maxHeight='92vh' footer={ , @@ -73,7 +75,7 @@ function AttachmentViewerModal({ config }) { )} {imgError ? ( - Failed to load image. + {t('imageLoadFailed')} ) : ( } > diff --git a/src/views/Modals/Inputs/TextModal.jsx b/src/views/Modals/Inputs/TextModal.jsx index 1a1c8a2..1e945ef 100644 --- a/src/views/Modals/Inputs/TextModal.jsx +++ b/src/views/Modals/Inputs/TextModal.jsx @@ -2,6 +2,7 @@ import { Textarea } from '@mui/joy' import { useState } from 'react' import ModalActions from '../../../components/common/ModalActions' import { useResponsiveModal } from '../../../hooks/useResponsiveModal' +import { useTranslation } from 'react-i18next' function TextModal({ isOpen, @@ -12,6 +13,7 @@ function TextModal({ okText, cancelText, }) { + const { t } = useTranslation('common') const { ResponsiveModal } = useResponsiveModal() const [text, setText] = useState(current) @@ -35,7 +37,7 @@ function TextModal({ >