feat: enhance localization and user experience across various components
- Added new localization keys for project management, settings, and onboarding views. - Updated labels and placeholders in project modal, password change modal, and backup/restore modal for better clarity. - Improved user feedback messages for password mismatch and backup creation failures. - Enhanced the timer details view with localized strings for active work, sessions, and distribution. - Refined the due date picker modal with localized quick date and time options. - Updated photo task modal and scan panel to include localized text for better accessibility. - Ensured consistent use of translation function for all user-facing strings.
This commit is contained in:
@@ -20,8 +20,9 @@
|
||||
"build-cf": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build",
|
||||
"build-selfhosted": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build --mode selfhosted",
|
||||
"build-win": "del package-lock.json && npm install && npm install --force @rollup/rollup-win32-x64-msvc @swc/core-win32-x64-msvc && vite build --mode selfhosted",
|
||||
"lint": "eslint && prettier -c .",
|
||||
"lint:ci": "eslint -f gha && prettier -c .",
|
||||
"lint": "eslint && prettier -c . && npm run lint:i18n",
|
||||
"lint:i18n": "node scripts/i18n-audit.mjs",
|
||||
"lint:ci": "eslint -f gha && prettier -c . && npm run lint:i18n",
|
||||
"lint:fix": "eslint --fix && prettier -w .",
|
||||
"preview": "vite preview",
|
||||
"setup-m1": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
|
||||
|
||||
@@ -259,7 +259,18 @@
|
||||
"clearFilters": "Clear filters",
|
||||
"count_one": "{{count}} archived task",
|
||||
"count_other": "{{count}} archived tasks",
|
||||
"matching": " matching \"{{term}}\""
|
||||
"matching": " matching \"{{term}}\"",
|
||||
"filtersTitle": "Filters",
|
||||
"sortArchivedDate": "Archived date",
|
||||
"sortName": "Name",
|
||||
"sortPriority": "Priority",
|
||||
"sortDueDate": "Due date",
|
||||
"noMatchTitle": "No archived tasks match",
|
||||
"noMatchSearch": "Nothing in the archive matches \"{{term}}\".",
|
||||
"noMatchFilters": "There are archived tasks, but none fit the filters that are currently on.",
|
||||
"emptyTitle": "Nothing archived",
|
||||
"emptyDescription": "Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.",
|
||||
"backToTasks": "Back to tasks"
|
||||
},
|
||||
"edit": {
|
||||
"deleteConfirm": "Are you sure you want to delete this chore?"
|
||||
@@ -374,7 +385,11 @@
|
||||
"search": "Search",
|
||||
"newChore": "New Chore",
|
||||
"unassigned": "Unassigned",
|
||||
"changeDueDate": "Change due date"
|
||||
"changeDueDate": "Change due date",
|
||||
"colDue": "Due",
|
||||
"colChore": "Chore",
|
||||
"colAssignee": "Assignee",
|
||||
"colAction": "Action"
|
||||
},
|
||||
"dataError": {
|
||||
"attachmentsFailed": "Failed to fetch attachments"
|
||||
@@ -404,7 +419,21 @@
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"weekend": "Weekend",
|
||||
"nextWeek": "Next week"
|
||||
"nextWeek": "Next week",
|
||||
"title": "Due Date",
|
||||
"apply": "Apply",
|
||||
"remove": "Remove",
|
||||
"quickDate": "Quick date",
|
||||
"quickTime": "Quick time",
|
||||
"nextMonth": "Next month",
|
||||
"morning": "Morning",
|
||||
"noon": "Noon",
|
||||
"afternoon": "Afternoon",
|
||||
"evening": "Evening",
|
||||
"night": "Night",
|
||||
"customTime": "Custom time",
|
||||
"anytime": "Anytime",
|
||||
"specificTime": "Specific time"
|
||||
},
|
||||
"photoTask": {
|
||||
"title": "Scan photo to create task",
|
||||
@@ -415,7 +444,13 @@
|
||||
"processNatively": "Process Natively",
|
||||
"processImage": "Process Image",
|
||||
"createTask": "Create Task",
|
||||
"failedScan": "Failed scan"
|
||||
"failedScan": "Failed scan",
|
||||
"retake": "Retake",
|
||||
"upload": "Upload",
|
||||
"keepAsAttachment": "Keep photo as attachment",
|
||||
"altCaptured": "Captured document",
|
||||
"altProcessing": "Processing",
|
||||
"taskIdentified": "Task identified"
|
||||
},
|
||||
"descriptionPlaceholder": "Enter description...",
|
||||
"notificationTemplate": {
|
||||
|
||||
@@ -338,6 +338,50 @@
|
||||
"tagHome": "Home",
|
||||
"tagPets": "Pets",
|
||||
"tagCooking": "Cooking"
|
||||
},
|
||||
"slides": {
|
||||
"problemTitle": "Stop forgetting the little things",
|
||||
"problemBody": "Donetick remembers the last time it happened, and when it's due next. So nobody else has to.",
|
||||
"captureTitle": "Add it before you forget it",
|
||||
"captureBody": "Type it, say it out loud, or snap a photo. Donetick fills in the due date, priority, labels, and who's on it.",
|
||||
"scheduleTitle": "It comes back exactly when it should",
|
||||
"scheduleBody": "Every week, every 3 months, or a month after you actually did it. Finishing late doesn't throw the rest of the year off.",
|
||||
"circleTitle": "Everyone gets a turn, No more arguing",
|
||||
"circleBody": "Rotate in the order you pick, or at random. Donetick remembers who went last, so turns stay fair.",
|
||||
"remindersTitle": "A nudge right when it matters & Widgets!",
|
||||
"remindersBody": "Get a reminder before something's due, or send a heads-up when someone forgets theirs. Today's list sits on your home screen too."
|
||||
},
|
||||
"carousel": {
|
||||
"skip": "Skip",
|
||||
"ariaLabel": "What you can do with Donetick",
|
||||
"goToStep": "Go to step {{number}}",
|
||||
"slidePosition": "{{index}} of {{total}}",
|
||||
"turnOnReminders": "Turn on reminders",
|
||||
"notNow": "Not now",
|
||||
"next": "Next"
|
||||
},
|
||||
"heardAbout": {
|
||||
"title": "Where'd you hear about Donetick?",
|
||||
"subtitle": "Helps us know what's working. Totally optional.",
|
||||
"detailPlaceholder": "Tell us where",
|
||||
"continue": "Continue",
|
||||
"skip": "Skip",
|
||||
"sources": {
|
||||
"appStore": "App Store / Google Play",
|
||||
"reddit": "Reddit or a forum",
|
||||
"friend": "Friend or family",
|
||||
"video": "YouTube or TikTok",
|
||||
"search": "Search engine",
|
||||
"other": "Something else"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Help us improve Donetick",
|
||||
"subtitle": "Your server, your data. Both of these are off from this point unless you turn them on.",
|
||||
"crashReportsLabel": "Share crash reports",
|
||||
"crashReportsDescription": "When Donetick crashes, send us the error and what led to it (device, app version, the screen you were on) no task content. It's how we catch bugs that never show up in our own testing and ship fixes faster.",
|
||||
"analyticsLabel": "Share anonymous usage analytics",
|
||||
"analyticsDescription": "Send anonymous usage events which features get used, which get ignored so we know what to build next instead of guessing. No task content, no personal data, nothing tied back to you."
|
||||
}
|
||||
},
|
||||
"errorReport": {
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
"errorUpdate": "Failed to update project",
|
||||
"errorCreate": "Failed to create project",
|
||||
"namePlaceholder": "Enter project name...",
|
||||
"descriptionPlaceholder": "Optional project description..."
|
||||
"descriptionPlaceholder": "Optional project description...",
|
||||
"nameLabel": "Project Name",
|
||||
"descriptionLabel": "Description",
|
||||
"iconLabel": "Project Icon",
|
||||
"selectIcon": "Select Icon",
|
||||
"colorLabel": "Project Color"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +537,13 @@
|
||||
},
|
||||
"passwordChange": {
|
||||
"minLength": "Password must be at least 8 characters",
|
||||
"maxLength": "Password must be less than 64 characters"
|
||||
"maxLength": "Password must be less than 64 characters",
|
||||
"description": "Choose a password between 8 and 64 characters.",
|
||||
"mismatch": "Passwords do not match",
|
||||
"newPassword": "New password",
|
||||
"newPasswordPlaceholder": "Enter password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"confirmPasswordPlaceholder": "Repeat password"
|
||||
},
|
||||
"payment": {
|
||||
"cancelledTitle": "Payment has been cancelled",
|
||||
@@ -569,7 +575,12 @@
|
||||
"includeAssets": "Include uploaded files and assets",
|
||||
"warningLabel": "Warning:",
|
||||
"restoreWarning": "Restoring a backup will replace all your current data. This action cannot be undone.",
|
||||
"restoreKeyPlaceholder": "Enter the encryption key used for this backup"
|
||||
"restoreKeyPlaceholder": "Enter the encryption key used for this backup",
|
||||
"title": "Backup & Restore",
|
||||
"createTab": "Create Backup",
|
||||
"restoreTab": "Restore Backup",
|
||||
"creating": "Creating backup...",
|
||||
"restoring": "Restoring backup..."
|
||||
},
|
||||
"deletion": {
|
||||
"passwordRequired": "Please enter your password to continue",
|
||||
@@ -579,10 +590,25 @@
|
||||
"transferIntro": "You own circles that require ownership transfer before deletion. Please select new owners:",
|
||||
"selectOwner": "Select new owner",
|
||||
"confirmPrompt": "Please enter your password and type DELETE to confirm",
|
||||
"typeDelete": "DELETE"
|
||||
"typeDelete": "DELETE",
|
||||
"warningLead": "This action cannot be undone.",
|
||||
"warningIntro": "Deleting your account will permanently remove:",
|
||||
"removesProfile": "Your user profile and authentication data",
|
||||
"removesChores": "All your chores, chore history, and time tracking sessions",
|
||||
"removesTokens": "API tokens, MFA sessions, and password reset tokens",
|
||||
"removesStorage": "Storage files and usage data",
|
||||
"removesPoints": "Points history and notifications",
|
||||
"removesCircles": "Circle memberships and relationships",
|
||||
"passwordToContinue": "Enter your password to continue",
|
||||
"circleLabel": "Circle: {{name}}",
|
||||
"newOwner": "New Owner",
|
||||
"logoutNotice": "On successful deletion, you will be logged out and redirected to the login page.",
|
||||
"passwordLabel": "Password",
|
||||
"typeToConfirm": "Type \"{{word}}\" to confirm"
|
||||
},
|
||||
"realtime": {
|
||||
"titleSse": "Real-time Updates (SSE)"
|
||||
"titleSse": "Real-time Updates (SSE)",
|
||||
"subtitleSse": "Get instant notifications via Server-Sent Events"
|
||||
},
|
||||
"nativeCancel": {
|
||||
"title": "Cancel Subscription",
|
||||
|
||||
@@ -35,5 +35,11 @@
|
||||
"endTime": "End Time",
|
||||
"leaveEmpty": "Leave empty if session is ongoing",
|
||||
"noSessionForChore": "No timer session found for this chore.",
|
||||
"saveChanges": "Save Changes"
|
||||
"saveChanges": "Save Changes",
|
||||
"emptySessionsTitle": "No work sessions yet",
|
||||
"emptySessionsDescription": "Start the timer on this task and each session lands here.",
|
||||
"percentActive": "{{percent}}% active",
|
||||
"activityTimeline": "Activity Timeline",
|
||||
"liveSession": "Live Session",
|
||||
"live": "Live"
|
||||
}
|
||||
|
||||
@@ -81,12 +81,12 @@ const SSESettings = () => {
|
||||
{t('realtime.titleSse')}
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
{t('common.plusFeature')}
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Get instant notifications via Server-Sent Events
|
||||
{t('realtime.subtitleSse')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isSSEEnabled() && isPlusAccount(userProfile) && (
|
||||
|
||||
@@ -765,7 +765,7 @@ const ArchivedTasks = () => {
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={() => setIsFilterSheetOpen(true)}
|
||||
title='Filters'
|
||||
title={t('archived.filtersTitle')}
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
@@ -774,10 +774,10 @@ const ArchivedTasks = () => {
|
||||
{/* Sort Menu */}
|
||||
<SortAndFilterMenu
|
||||
sortOptions={[
|
||||
{ name: 'Archived date', value: 'archivedAt' },
|
||||
{ name: 'Name', value: 'name' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Due date', value: 'dueDate' },
|
||||
{ name: t('archived.sortArchivedDate'), value: 'archivedAt' },
|
||||
{ name: t('archived.sortName'), value: 'name' },
|
||||
{ name: t('archived.sortPriority'), value: 'priority' },
|
||||
{ name: t('archived.sortDueDate'), value: 'dueDate' },
|
||||
]}
|
||||
selectedSort={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
@@ -1045,11 +1045,11 @@ const ArchivedTasks = () => {
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title='No archived tasks match'
|
||||
title={t('archived.noMatchTitle')}
|
||||
description={
|
||||
searchTerm
|
||||
? `Nothing in the archive matches "${searchTerm}".`
|
||||
: 'There are archived tasks, but none fit the filters that are currently on.'
|
||||
? t('archived.noMatchSearch', { term: searchTerm })
|
||||
: t('archived.noMatchFilters')
|
||||
}
|
||||
primaryAction={
|
||||
searchTerm
|
||||
@@ -1069,9 +1069,9 @@ const ArchivedTasks = () => {
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<Archive />}
|
||||
title='Nothing archived'
|
||||
description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.'
|
||||
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
||||
title={t('archived.emptyTitle')}
|
||||
description={t('archived.emptyDescription')}
|
||||
primaryAction={{ label: t('archived.backToTasks'), to: '/chores' }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -180,11 +180,11 @@ const ChoresOverview = () => {
|
||||
<thead>
|
||||
<tr>
|
||||
{/* first column has minium size because its icon */}
|
||||
<th style={{ width: 100 }}>Due</th>
|
||||
<th>Chore</th>
|
||||
<th>Assignee</th>
|
||||
<th>Due</th>
|
||||
<th>Action</th>
|
||||
<th style={{ width: 100 }}>{t('overview.colDue')}</th>
|
||||
<th>{t('overview.colChore')}</th>
|
||||
<th>{t('overview.colAssignee')}</th>
|
||||
<th>{t('overview.colDue')}</th>
|
||||
<th>{t('overview.colAction')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -104,7 +104,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
handleClose()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.message || 'Failed to create backup')
|
||||
setError(errorData.message || t('backup.createFailed'))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('backup.createFailed'))
|
||||
@@ -156,7 +156,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
handleClose()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.message || 'Failed to restore backup')
|
||||
setError(errorData.message || t('backup.restoreFailed'))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('backup.restoreFailed'))
|
||||
@@ -288,7 +288,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
unmountDelay={250}
|
||||
title='Backup & Restore'
|
||||
title={t('backup.title')}
|
||||
closeOnBackdrop={!loading}
|
||||
closeOnEscape={!loading}
|
||||
footer={
|
||||
@@ -299,7 +299,8 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
disabled: loading,
|
||||
}}
|
||||
primary={{
|
||||
label: activeTab === 0 ? 'Create Backup' : 'Restore Backup',
|
||||
label:
|
||||
activeTab === 0 ? t('backup.createTab') : t('backup.restoreTab'),
|
||||
color: activeTab === 0 ? 'primary' : 'warning',
|
||||
onClick: activeTab === 0 ? handleCreateBackup : handleRestore,
|
||||
loading,
|
||||
@@ -320,7 +321,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
>
|
||||
<CircularProgress />
|
||||
<Typography level='body-md' sx={{ ml: 2 }}>
|
||||
{activeTab === 0 ? 'Creating backup...' : 'Restoring backup...'}
|
||||
{activeTab === 0 ? t('backup.creating') : t('backup.restoring')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -329,8 +330,8 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
onChange={(event, newValue) => setActiveTab(newValue)}
|
||||
>
|
||||
<TabList>
|
||||
<Tab>Create Backup</Tab>
|
||||
<Tab>Restore Backup</Tab>
|
||||
<Tab>{t('backup.createTab')}</Tab>
|
||||
<Tab>{t('backup.restoreTab')}</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value={0}>{renderBackupTab()}</TabPanel>
|
||||
|
||||
@@ -18,7 +18,7 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
||||
if (!passwordTouched || !confirmPasswordTouched) return
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match')
|
||||
setPasswordError(t('passwordChange.mismatch'))
|
||||
} else if (password.length < 8) {
|
||||
setPasswordError(t('passwordChange.minLength'))
|
||||
} else if (password.length > 64) {
|
||||
@@ -42,7 +42,7 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
||||
onClose={() => handleAction(false)}
|
||||
size='sm'
|
||||
title={t('accountSettings.changePassword')}
|
||||
description='Choose a password between 8 and 64 characters.'
|
||||
description={t('passwordChange.description')}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
@@ -58,13 +58,15 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
||||
}
|
||||
>
|
||||
<FormControl sx={{ mb: 2 }}>
|
||||
<Typography level='body-sm'>New password</Typography>
|
||||
<Typography level='body-sm'>
|
||||
{t('passwordChange.newPassword')}
|
||||
</Typography>
|
||||
<Input
|
||||
required
|
||||
name='password'
|
||||
type='password'
|
||||
autoComplete='new-password'
|
||||
placeholder='Enter password'
|
||||
placeholder={t('passwordChange.newPasswordPlaceholder')}
|
||||
value={password}
|
||||
onChange={event => {
|
||||
setPasswordTouched(true)
|
||||
@@ -74,13 +76,15 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
||||
</FormControl>
|
||||
|
||||
<FormControl error={Boolean(passwordError)}>
|
||||
<Typography level='body-sm'>Confirm password</Typography>
|
||||
<Typography level='body-sm'>
|
||||
{t('passwordChange.confirmPassword')}
|
||||
</Typography>
|
||||
<Input
|
||||
required
|
||||
name='confirmPassword'
|
||||
type='password'
|
||||
autoComplete='new-password'
|
||||
placeholder='Repeat password'
|
||||
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={event => {
|
||||
setConfirmPasswordTouched(true)
|
||||
|
||||
@@ -151,7 +151,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
<Stack spacing={3}>
|
||||
{/* Project Name */}
|
||||
<FormControl required>
|
||||
<FormLabel>Project Name</FormLabel>
|
||||
<FormLabel>{t('modal.nameLabel')}</FormLabel>
|
||||
<Input
|
||||
value={projectName}
|
||||
onChange={e => setProjectName(e.target.value)}
|
||||
@@ -163,7 +163,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
|
||||
{/* Project Description */}
|
||||
<FormControl>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel>{t('modal.descriptionLabel')}</FormLabel>
|
||||
<Textarea
|
||||
value={projectDescription}
|
||||
onChange={e => setProjectDescription(e.target.value)}
|
||||
@@ -176,7 +176,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
|
||||
{/* Icon Selection */}
|
||||
<FormControl>
|
||||
<FormLabel>Project Icon</FormLabel>
|
||||
<FormLabel>{t('modal.iconLabel')}</FormLabel>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={() => setIsIconPickerOpen(true)}
|
||||
@@ -212,19 +212,20 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{PROJECT_ICONS.find(icon => icon.value === projectIcon)?.name ||
|
||||
'Select Icon'}
|
||||
{PROJECT_ICONS.some(icon => icon.value === projectIcon)
|
||||
? t(`icons.${projectIcon}`)
|
||||
: t('modal.selectIcon')}
|
||||
</Button>
|
||||
</FormControl>
|
||||
|
||||
{/* Color Selection */}
|
||||
<FormControl>
|
||||
<FormLabel>Project Color</FormLabel>
|
||||
<FormLabel>{t('modal.colorLabel')}</FormLabel>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{PROJECT_COLORS.map(colorOption => (
|
||||
<Box
|
||||
key={colorOption.value}
|
||||
title={colorOption.name}
|
||||
title={t(`common:colors.${colorOption.name}`)}
|
||||
onClick={() => setProjectColor(colorOption.value)}
|
||||
sx={{
|
||||
width: 26,
|
||||
|
||||
@@ -17,6 +17,10 @@ import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
|
||||
|
||||
// Sent verbatim to the delete endpoint, so it stays English in every locale —
|
||||
// the label around it is what gets translated.
|
||||
const CONFIRMATION_WORD = 'DELETE'
|
||||
|
||||
function UserDeletionModal({ isOpen, onClose }) {
|
||||
const { t } = useTranslation('settings')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
@@ -99,7 +103,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
}
|
||||
|
||||
const executeUserDeletion = async () => {
|
||||
if (password.trim() === '' || confirmation !== 'DELETE') {
|
||||
if (password.trim() === '' || confirmation !== CONFIRMATION_WORD) {
|
||||
setError(t('deletion.passwordAndDelete'))
|
||||
return
|
||||
}
|
||||
@@ -153,33 +157,27 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
const renderWarningStep = () => (
|
||||
<>
|
||||
<Typography level='body-md' mb={2}>
|
||||
<strong>This action cannot be undone.</strong> Deleting your account
|
||||
will permanently remove:
|
||||
<strong>{t('deletion.warningLead')}</strong>{' '}
|
||||
{t('deletion.warningIntro')}
|
||||
</Typography>
|
||||
|
||||
<Box mb={3}>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Your user profile and authentication data
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• All your chores, chore history, and time tracking sessions
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• API tokens, MFA sessions, and password reset tokens
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Storage files and usage data
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Points history and notifications
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Circle memberships and relationships
|
||||
</Typography>
|
||||
{[
|
||||
'removesProfile',
|
||||
'removesChores',
|
||||
'removesTokens',
|
||||
'removesStorage',
|
||||
'removesPoints',
|
||||
'removesCircles',
|
||||
].map(key => (
|
||||
<Typography key={key} level='body-sm' mb={1}>
|
||||
• {t(`deletion.${key}`)}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<FormControl sx={{ mb: 2 }}>
|
||||
<FormLabel>Enter your password to continue</FormLabel>
|
||||
<FormLabel>{t('deletion.passwordToContinue')}</FormLabel>
|
||||
<Input
|
||||
type='password'
|
||||
value={password}
|
||||
@@ -200,16 +198,15 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
<>
|
||||
<Typography level='body-md' mb={3}>
|
||||
{t('deletion.transferIntro')}
|
||||
select new owners:
|
||||
</Typography>
|
||||
|
||||
{circlesRequiringTransfer.map(circle => (
|
||||
<Card key={circle.id} sx={{ mb: 2, p: 2 }}>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Circle: {circle.name}
|
||||
{t('deletion.circleLabel', { name: circle.name })}
|
||||
</Typography>
|
||||
<FormControl>
|
||||
<FormLabel>New Owner</FormLabel>
|
||||
<FormLabel>{t('deletion.newOwner')}</FormLabel>
|
||||
<Select
|
||||
placeholder={t('deletion.selectOwner')}
|
||||
value={
|
||||
@@ -243,12 +240,11 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
{t('deletion.confirmPrompt')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={2}>
|
||||
on successful deletion, you will be logged out and redirected to the
|
||||
login page.
|
||||
{t('deletion.logoutNotice')}
|
||||
</Typography>
|
||||
|
||||
<FormControl sx={{ mb: 2 }}>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t('deletion.passwordLabel')}</FormLabel>
|
||||
<Input
|
||||
type='password'
|
||||
value={password}
|
||||
@@ -258,7 +254,9 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ mb: 3 }}>
|
||||
<FormLabel>Type "DELETE" to confirm</FormLabel>
|
||||
<FormLabel>
|
||||
{t('deletion.typeToConfirm', { word: CONFIRMATION_WORD })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
value={confirmation}
|
||||
onChange={e => setConfirmation(e.target.value)}
|
||||
@@ -323,7 +321,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
? !password
|
||||
: step === 2
|
||||
? circlesRequiringTransfer.length !== transferOptions.length
|
||||
: !password || confirmation !== 'DELETE',
|
||||
: !password || confirmation !== CONFIRMATION_WORD,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CheckRounded } from '@mui/icons-material'
|
||||
import { Box, Button, Input, Link, Switch, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { track } from '../../analytics'
|
||||
@@ -23,16 +24,12 @@ const enter = (delay = 0) => ({
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
})
|
||||
|
||||
const SOURCES = [
|
||||
'App Store / Google Play',
|
||||
'Reddit or a forum',
|
||||
'Friend or family',
|
||||
'YouTube or TikTok',
|
||||
'Search engine',
|
||||
'Something else',
|
||||
]
|
||||
// Keys, not labels: the selected value is recorded as the acquisition source,
|
||||
// so it has to stay stable across locales. Display copy lives in
|
||||
// `common:onboarding.heardAbout.sources.*`.
|
||||
const SOURCES = ['appStore', 'reddit', 'friend', 'video', 'search', 'other']
|
||||
|
||||
const OTHER = 'Something else'
|
||||
const OTHER = 'other'
|
||||
|
||||
const Shell = ({ children }) => (
|
||||
<Box
|
||||
@@ -70,6 +67,7 @@ const Shell = ({ children }) => (
|
||||
* attribute, so it gets the privacy prompt below instead.
|
||||
*/
|
||||
const AcquisitionSurvey = ({ onDone }) => {
|
||||
const { t } = useTranslation()
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [detail, setDetail] = useState('')
|
||||
|
||||
@@ -102,13 +100,13 @@ const AcquisitionSurvey = ({ onDone }) => {
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
Where'd you hear about Donetick?
|
||||
{t('onboarding.heardAbout.title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
|
||||
>
|
||||
Helps us know what's working. Totally optional.
|
||||
{t('onboarding.heardAbout.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -143,7 +141,7 @@ const AcquisitionSurvey = ({ onDone }) => {
|
||||
transition: `border-color 200ms ${EASE}, background-color 200ms ${EASE}`,
|
||||
}}
|
||||
>
|
||||
{source}
|
||||
{t(`onboarding.heardAbout.sources.${source}`)}
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
@@ -170,7 +168,7 @@ const AcquisitionSurvey = ({ onDone }) => {
|
||||
{selected === OTHER && (
|
||||
<Box sx={{ mt: 0.5, ...enter(0) }}>
|
||||
<Input
|
||||
placeholder='Tell us where'
|
||||
placeholder={t('onboarding.heardAbout.detailPlaceholder')}
|
||||
value={detail}
|
||||
onChange={e => setDetail(e.target.value)}
|
||||
size='lg'
|
||||
@@ -183,7 +181,7 @@ const AcquisitionSurvey = ({ onDone }) => {
|
||||
|
||||
<Box sx={{ mt: 3, ...enter(120) }}>
|
||||
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
||||
Continue
|
||||
{t('onboarding.heardAbout.continue')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -196,27 +194,15 @@ const AcquisitionSurvey = ({ onDone }) => {
|
||||
underline='hover'
|
||||
onClick={finish}
|
||||
>
|
||||
Skip
|
||||
{t('onboarding.heardAbout.skip')}
|
||||
</Link>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PRIVACY_TOGGLES = [
|
||||
{
|
||||
key: 'crashReports',
|
||||
label: 'Share crash reports',
|
||||
description:
|
||||
"When Donetick crashes, send us the error and what led to it (device, app version, the screen you were on) no task content. It's how we catch bugs that never show up in our own testing and ship fixes faster.",
|
||||
},
|
||||
{
|
||||
key: 'analytics',
|
||||
label: 'Share anonymous usage analytics',
|
||||
description:
|
||||
'Send anonymous usage events which features get used, which get ignored so we know what to build next instead of guessing. No task content, no personal data, nothing tied back to you.',
|
||||
},
|
||||
]
|
||||
// Copy lives in `common:onboarding.privacy.*`, keyed off `key`.
|
||||
const PRIVACY_TOGGLES = ['crashReports', 'analytics']
|
||||
|
||||
/**
|
||||
* The self-hosted counterpart to the attribution survey: since a self-hosted
|
||||
@@ -225,6 +211,7 @@ const PRIVACY_TOGGLES = [
|
||||
* an opt-in, not an opt-out.
|
||||
*/
|
||||
const PrivacyPreferences = ({ onDone }) => {
|
||||
const { t } = useTranslation()
|
||||
const [crashReports, setCrashReports] = useState(false)
|
||||
const [analytics, setAnalytics] = useState(false)
|
||||
|
||||
@@ -253,14 +240,13 @@ const PrivacyPreferences = ({ onDone }) => {
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
Help us improve Donetick
|
||||
{t('onboarding.privacy.title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{ mt: 1, color: 'text.secondary', textWrap: 'pretty' }}
|
||||
>
|
||||
Your server, your data. Both of these are off from this point unless
|
||||
you turn them on.
|
||||
{t('onboarding.privacy.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -272,7 +258,7 @@ const PrivacyPreferences = ({ onDone }) => {
|
||||
...enter(60),
|
||||
}}
|
||||
>
|
||||
{PRIVACY_TOGGLES.map(({ description, key, label }) => (
|
||||
{PRIVACY_TOGGLES.map(key => (
|
||||
<Box
|
||||
key={key}
|
||||
sx={{
|
||||
@@ -290,13 +276,13 @@ const PrivacyPreferences = ({ onDone }) => {
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{label}
|
||||
{t(`onboarding.privacy.${key}Label`)}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ mt: 0.25, color: 'text.secondary' }}
|
||||
>
|
||||
{description}
|
||||
{t(`onboarding.privacy.${key}Description`)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
@@ -310,7 +296,7 @@ const PrivacyPreferences = ({ onDone }) => {
|
||||
|
||||
<Box sx={{ mt: 3, ...enter(120) }}>
|
||||
<Button size='lg' fullWidth onClick={finish} sx={authButtonSx}>
|
||||
Continue
|
||||
{t('onboarding.heardAbout.continue')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { track } from '../../analytics'
|
||||
@@ -31,75 +32,57 @@ const SWIPE_THRESHOLD = 56
|
||||
* back. Every title is an outcome — nobody buys "reschedule from completion
|
||||
* date", they buy never arguing about trash day again.
|
||||
*/
|
||||
// Copy lives in `common:onboarding.slides.*` — this array is module-level and
|
||||
// cannot call t(), so each slide carries its key and is translated at render.
|
||||
const SLIDES = [
|
||||
{
|
||||
key: 'problem',
|
||||
title: 'Stop forgetting the little things',
|
||||
body: "Donetick remembers the last time it happened, and when it's due next. So nobody else has to.",
|
||||
Visual: ProblemVignette,
|
||||
},
|
||||
{
|
||||
key: 'capture',
|
||||
title: 'Add it before you forget it',
|
||||
body: "Type it, say it out loud, or snap a photo. Donetick fills in the due date, priority, labels, and who's on it.",
|
||||
Visual: CaptureVignette,
|
||||
},
|
||||
// {
|
||||
// key: 'schedule',
|
||||
// title: 'It comes back exactly when it should',
|
||||
// body: "Every week, every 3 months, or a month after you actually did it. Finishing late doesn't throw the rest of the year off.",
|
||||
// Visual: ScheduleVignette,
|
||||
// },
|
||||
{
|
||||
key: 'circle',
|
||||
title: 'Everyone gets a turn, No more arguing',
|
||||
body: 'Rotate in the order you pick, or at random. Donetick remembers who went last, so turns stay fair.',
|
||||
Visual: TakesTurnsVignette,
|
||||
},
|
||||
{
|
||||
key: 'reminders',
|
||||
title: 'A nudge right when it matters & Widgets!',
|
||||
body: "Get a reminder before something's due, or send a heads-up when someone forgets theirs. Today's list sits on your home screen too.",
|
||||
Visual: RemindersVignette,
|
||||
permission: true,
|
||||
},
|
||||
{ key: 'problem', Visual: ProblemVignette },
|
||||
{ key: 'capture', Visual: CaptureVignette },
|
||||
// { key: 'schedule', Visual: ScheduleVignette },
|
||||
{ key: 'circle', Visual: TakesTurnsVignette },
|
||||
{ key: 'reminders', Visual: RemindersVignette, permission: true },
|
||||
]
|
||||
|
||||
const Dots = ({ activeIndex, count, onSelect }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
||||
{Array.from({ length: count }, (_, index) => {
|
||||
const active = index === activeIndex
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
component='button'
|
||||
type='button'
|
||||
aria-label={`Go to step ${index + 1}`}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
onClick={() => onSelect(index)}
|
||||
sx={{
|
||||
border: 'none',
|
||||
p: 0,
|
||||
cursor: 'pointer',
|
||||
height: 6,
|
||||
width: active ? 22 : 6,
|
||||
borderRadius: '999px',
|
||||
bgcolor: active ? 'primary.500' : 'neutral.softBg',
|
||||
transition: `width 280ms ${EASE}, background-color 280ms ease`,
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.500',
|
||||
outlineOffset: '3px',
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
const Dots = ({ activeIndex, count, onSelect }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
|
||||
{Array.from({ length: count }, (_, index) => {
|
||||
const active = index === activeIndex
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
component='button'
|
||||
type='button'
|
||||
aria-label={t('onboarding.carousel.goToStep', {
|
||||
number: index + 1,
|
||||
})}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
onClick={() => onSelect(index)}
|
||||
sx={{
|
||||
border: 'none',
|
||||
p: 0,
|
||||
cursor: 'pointer',
|
||||
height: 6,
|
||||
width: active ? 22 : 6,
|
||||
borderRadius: '999px',
|
||||
bgcolor: active ? 'primary.500' : 'neutral.softBg',
|
||||
transition: `width 280ms ${EASE}, background-color 280ms ease`,
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.500',
|
||||
outlineOffset: '3px',
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const OnboardingView = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [drag, setDrag] = useState(0)
|
||||
@@ -227,7 +210,7 @@ const OnboardingView = () => {
|
||||
onClick={finish}
|
||||
sx={{ fontWeight: 600, borderRadius: '999px' }}
|
||||
>
|
||||
Skip
|
||||
{t('onboarding.carousel.skip')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -235,7 +218,7 @@ const OnboardingView = () => {
|
||||
ref={viewportRef}
|
||||
role='group'
|
||||
aria-roledescription='carousel'
|
||||
aria-label='What you can do with Donetick'
|
||||
aria-label={t('onboarding.carousel.ariaLabel')}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
@@ -270,7 +253,10 @@ const OnboardingView = () => {
|
||||
key={slide.key}
|
||||
role='group'
|
||||
aria-roledescription='slide'
|
||||
aria-label={`${index + 1} of ${SLIDES.length}`}
|
||||
aria-label={t('onboarding.carousel.slidePosition', {
|
||||
index: index + 1,
|
||||
total: SLIDES.length,
|
||||
})}
|
||||
aria-hidden={!active}
|
||||
sx={{
|
||||
flex: '0 0 100%',
|
||||
@@ -320,7 +306,7 @@ const OnboardingView = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{slide.title}
|
||||
{t(`onboarding.slides.${slide.key}Title`)}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-md'
|
||||
@@ -333,7 +319,7 @@ const OnboardingView = () => {
|
||||
animation: `slideCopyIn 460ms ${EASE} 140ms both`,
|
||||
}}
|
||||
>
|
||||
{slide.body}
|
||||
{t(`onboarding.slides.${slide.key}Body`)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -366,7 +352,7 @@ const OnboardingView = () => {
|
||||
startDecorator={<NotificationsActiveRounded />}
|
||||
sx={authButtonSx}
|
||||
>
|
||||
Turn on reminders
|
||||
{t('onboarding.carousel.turnOnReminders')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
@@ -379,7 +365,7 @@ const OnboardingView = () => {
|
||||
onClick={finish}
|
||||
sx={{ ...authButtonSx, mt: -1.5 }}
|
||||
>
|
||||
Not now
|
||||
{t('onboarding.carousel.notNow')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -390,7 +376,7 @@ const OnboardingView = () => {
|
||||
endDecorator={!isLast ? <ArrowForwardRounded /> : null}
|
||||
sx={authButtonSx}
|
||||
>
|
||||
{isLast ? 'Get started' : 'Next'}
|
||||
{isLast ? t('getStarted') : t('onboarding.carousel.next')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -462,7 +462,7 @@ const TimerDetails = () => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Active Work
|
||||
{t('activeWork')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -572,7 +572,7 @@ const TimerDetails = () => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Sessions
|
||||
{t('sessions')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -661,12 +661,18 @@ const TimerDetails = () => {
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
|
||||
>
|
||||
Work vs Break Distribution
|
||||
{t('distribution')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
||||
{calculateCurrentActiveDuration() > 0
|
||||
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
|
||||
: 'No active time yet'}
|
||||
? t('percentActive', {
|
||||
percent: Math.round(
|
||||
(calculateCurrentActiveDuration() /
|
||||
calculateTotalDuration()) *
|
||||
100,
|
||||
),
|
||||
})
|
||||
: t('noActiveTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -695,7 +701,7 @@ const TimerDetails = () => {
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', fontWeight: 'medium', mb: 2 }}
|
||||
>
|
||||
Activity Timeline
|
||||
{t('activityTimeline')}
|
||||
</Typography>
|
||||
|
||||
{timerData &&
|
||||
@@ -803,7 +809,7 @@ const TimerDetails = () => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary' }}
|
||||
>
|
||||
Active Work
|
||||
{t('activeWork')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -852,7 +858,7 @@ const TimerDetails = () => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary' }}
|
||||
>
|
||||
Live Session
|
||||
{t('liveSession')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -918,7 +924,7 @@ const TimerDetails = () => {
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography level='h4'>Session Breakdown</Typography>
|
||||
<Typography level='h4'>{t('sessionBreakdown')}</Typography>
|
||||
{!editingSessions[timerData.id] && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
@@ -1112,7 +1118,7 @@ const TimerDetails = () => {
|
||||
variant='soft'
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
>
|
||||
Live
|
||||
{t('live')}
|
||||
</Chip>
|
||||
)}
|
||||
{/* User chip showing who started the session */}
|
||||
@@ -1211,8 +1217,8 @@ const TimerDetails = () => {
|
||||
<EmptyState
|
||||
size='sm'
|
||||
icon={<AccessTime />}
|
||||
title='No work sessions yet'
|
||||
description='Start the timer on this task and each session lands here.'
|
||||
title={t('emptySessionsTitle')}
|
||||
description={t('emptySessionsDescription')}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1237,7 +1243,7 @@ const TimerDetails = () => {
|
||||
}}
|
||||
>
|
||||
<Typography level='body-md' sx={{ fontWeight: 'bold' }}>
|
||||
Sessions
|
||||
{t('sessions')}
|
||||
</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -1340,9 +1346,7 @@ const TimerDetails = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Leave empty if session is ongoing
|
||||
</FormHelperText>
|
||||
<FormHelperText>{t('leaveEmpty')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Box>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
@@ -93,16 +94,17 @@ const toDateKey = date => moment(date).format('YYYY-MM-DD')
|
||||
* reschedule a task — task cards, swipe actions, action menus.
|
||||
*/
|
||||
const DueDatePickerModal = ({
|
||||
applyLabel = 'Apply',
|
||||
applyLabel,
|
||||
dueDateOnly,
|
||||
dueTime,
|
||||
onApply,
|
||||
onClose,
|
||||
onRemove,
|
||||
open,
|
||||
title = 'Due Date',
|
||||
title,
|
||||
useCustomTime,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { firstDayOfWeek } = useLocalization()
|
||||
|
||||
@@ -172,21 +174,24 @@ const DueDatePickerModal = ({
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
title={title ?? t('duePicker.title')}
|
||||
fullWidth={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
tertiary={
|
||||
onRemove && dueDateOnly
|
||||
? {
|
||||
label: 'Remove',
|
||||
label: t('duePicker.remove'),
|
||||
color: 'danger',
|
||||
onClick: onRemove,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: applyLabel, onClick: handleSave }}
|
||||
secondary={{ label: t('common:cancel'), onClick: onClose }}
|
||||
primary={{
|
||||
label: applyLabel ?? t('duePicker.apply'),
|
||||
onClick: handleSave,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -201,33 +206,33 @@ const DueDatePickerModal = ({
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick date
|
||||
{t('duePicker.quickDate')}
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
label: t('duePicker.today'),
|
||||
icon: <Today sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow',
|
||||
label: 'Tomorrow',
|
||||
label: t('duePicker.tomorrow'),
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'Weekend',
|
||||
label: t('duePicker.weekend'),
|
||||
icon: <Weekend sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-week',
|
||||
label: 'Next week',
|
||||
label: t('duePicker.nextWeek'),
|
||||
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-month',
|
||||
label: 'Next month',
|
||||
label: t('duePicker.nextMonth'),
|
||||
icon: <EventNote sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => {
|
||||
@@ -268,33 +273,33 @@ const DueDatePickerModal = ({
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick time
|
||||
{t('duePicker.quickTime')}
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
time: '09:00',
|
||||
label: 'Morning',
|
||||
label: t('duePicker.morning'),
|
||||
icon: <LightMode sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '12:00',
|
||||
label: 'Noon',
|
||||
label: t('duePicker.noon'),
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '15:00',
|
||||
label: 'Afternoon',
|
||||
label: t('duePicker.afternoon'),
|
||||
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '18:00',
|
||||
label: 'Evening',
|
||||
label: t('duePicker.evening'),
|
||||
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '22:00',
|
||||
label: 'Night',
|
||||
label: t('duePicker.night'),
|
||||
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => (
|
||||
@@ -506,7 +511,7 @@ const DueDatePickerModal = ({
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Custom time
|
||||
{t('duePicker.customTime')}
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
@@ -525,7 +530,7 @@ const DueDatePickerModal = ({
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(false)}
|
||||
>
|
||||
Anytime
|
||||
{t('duePicker.anytime')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -534,7 +539,7 @@ const DueDatePickerModal = ({
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(true)}
|
||||
>
|
||||
Specific time
|
||||
{t('duePicker.specificTime')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -350,7 +350,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
||||
{phase === 'preview' && capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Captured document'
|
||||
alt={t('photoTask.altCaptured')}
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
@@ -372,7 +372,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
||||
{capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Processing'
|
||||
alt={t('photoTask.altProcessing')}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
@@ -408,7 +408,9 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle color='success' />
|
||||
<Typography level='title-sm'>Task identified</Typography>
|
||||
<Typography level='title-sm'>
|
||||
{t('photoTask.taskIdentified')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -531,7 +533,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
{t('photoTask.retake')}
|
||||
</Button>
|
||||
{isNativeScanner &&
|
||||
capturedImage &&
|
||||
@@ -570,7 +572,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
{t('photoTask.retake')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -591,7 +593,7 @@ const PhotoTaskModal = ({ onClose, onTaskExtracted, open }) => {
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
{t('photoTask.retake')}
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' onClick={handleConfirm}>
|
||||
{t('photoTask.createTask')}
|
||||
|
||||
@@ -132,7 +132,7 @@ const ScanPanel = ({
|
||||
}
|
||||
}
|
||||
if (phase === 'error') {
|
||||
return { label: 'Retake', icon: <Replay />, onClick: retake }
|
||||
return { label: t('photoTask.retake'), icon: <Replay />, onClick: retake }
|
||||
}
|
||||
return null
|
||||
}, [
|
||||
@@ -160,7 +160,7 @@ const ScanPanel = ({
|
||||
size='sm'
|
||||
checked={keepImage}
|
||||
onChange={e => setKeepImage(e.target.checked)}
|
||||
label='Keep photo as attachment'
|
||||
label={t('photoTask.keepAsAttachment')}
|
||||
sx={{ '--Checkbox-size': '18px' }}
|
||||
/>
|
||||
)
|
||||
@@ -248,7 +248,7 @@ const ScanPanel = ({
|
||||
startDecorator={<PhotoCamera fontSize='small' />}
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
Upload
|
||||
{t('photoTask.upload')}
|
||||
</Button>
|
||||
)}
|
||||
{keepImageToggle}
|
||||
@@ -278,7 +278,7 @@ const ScanPanel = ({
|
||||
>
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Processing'
|
||||
alt={t('photoTask.altProcessing')}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
|
||||
Reference in New Issue
Block a user