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