feat: add i18n support to Settings and AddTaskModal components

- Integrated translation functionality using react-i18next in Settings.jsx
- Replaced hardcoded strings with translation keys for better localization support
- Updated AddTaskModal.jsx to include translations for task-related strings
- Enhanced user experience by providing localized messages for various actions and notifications
This commit is contained in:
Mo Tarbin
2026-08-18 01:11:26 -04:00
parent cc2e656e14
commit c1318a2cc4
7 changed files with 1130 additions and 835 deletions

View File

@@ -420,5 +420,138 @@
"dueAlert": "Due Alert",
"followUp": "Follow-up",
"rememberFuture": "Remember for Future Tasks"
},
"choreEdit": {
"errNameRequired": "Name is required",
"errFrequencyInvalid": "Invalid frequency, the {{unit}} should be > 0",
"errSelectDayOfWeek": "Please select at least one day of the week",
"errSelectDayOccurrence": "Please select at least one day occurrence for the month",
"errSelectMonth": "Please select at least one month",
"errStartDateRequired": "Start date is required",
"errDueDateRequired": "Due date is required",
"errThingTrigger": "Thing trigger is invalid",
"errTitle": "Please resolve the following errors:",
"savedOfflineTitle": "Saved Offline",
"savedOfflineMessage": "Your changes will sync when you are back online.",
"savedTitle": "Chore Saved",
"savedMessage": "Your task has been saved successfully!",
"saveFailedTitle": "Save Failed",
"saveFailedMessage": "Failed to save chore, please try again.",
"uploadFailedTitle": "Upload Failed",
"uploadFailedMessage": "Failed to upload attachment.",
"scanFailedTitle": "Scan Failed",
"scanFailedMessage": "Could not scan the document.",
"scanReadFailedMessage": "Could not read the scanned image.",
"deleteFailedTitle": "Delete Failed",
"deleteAttachmentFailed": "Failed to delete attachment.",
"deleteChoreFailed": "Failed to delete chore: {{error}}",
"deleteChoreTitle": "Delete Chore",
"name": "Name",
"nameDesc": "What is the name of this task?",
"description": "Description",
"descriptionDesc": "What is this task about?",
"priority": "Priority",
"priorityDesc": "How important is this task?",
"noPriority": "No Priority",
"project": "Project",
"projectDesc": "Which project does this task belong to?",
"defaultProject": "Default Project",
"labels": "Labels",
"labelsDesc": "Things to remember about this task or to tag it",
"addNewLabel": "Add New Label",
"subTasks": "Sub Tasks",
"attachments": "Attachments",
"attachmentsDesc": "Files attached to this task",
"uploadFile": "Upload File",
"scan": "Scan",
"assignees": "Assignees",
"assigneesDesc": "Who can do this task?",
"anyone": "Anyone",
"rememberFuture": "Remember for Future Tasks",
"currentlyAssigned": "Currently Assigned To",
"currentlyAssignedDesc": "Who is assigned the next due?",
"noAssigneesPlaceholder": "No Assignees yet can perform this task",
"selectAssigneePlaceholder": "Select an assignee for this task",
"assignStrategy": "Assignment Strategy",
"assignStrategyDesc": "How to pick the next assignee for the following task?",
"strategy": {
"random": "Random",
"least_assigned": "Least Assigned",
"least_completed": "Least Completed",
"keep_last_assigned": "Keep Last Assigned",
"random_except_last_assigned": "Random Except Last Assigned",
"round_robin": "Round Robin",
"no_assignee": "No Assignee"
},
"startDate": "Start Date",
"dueDate": "Due Date",
"triggerDueHint": "Due Date will be set when the trigger of the thing is met",
"giveDueDate": "Give this task a due date",
"giveDueDateHelp": "Task needs to be completed by a specific time",
"startWhen": "When does this task start?",
"dueWhen": "When is the next first time this task is due?",
"setSpecificTime": "Set a specific time",
"dueAtSpecifiedTime": "Task will be due at the specified time",
"dueEndOfDay": "Task will be due at the end of the day (11:59 PM)",
"timeLabel": "Time:",
"taskWindow": "Task Window",
"taskWindowDesc": "Define when this task can be completed and when it expires",
"earliestCompletion": "Set earliest completion time",
"earliestCompletionHelp": "Task becomes available to complete X hours before the due date",
"hoursLabel": "Hours:",
"hoursPlaceholder": "Hours",
"afterDueDate": "after due date",
"schedulingPrefs": "Scheduling Preferences",
"schedulingPrefsDesc": "How to reschedule the next due date?",
"rescheduleFromDue": "Reschedule from due date",
"rescheduleFromDueHelp": "the next task will be scheduled from the original due date, even if the previous task was completed late",
"rescheduleFromCompletion": "Reschedule from completion date",
"rescheduleFromCompletionHelp": "the next task will be scheduled from the actual completion date of the previous task",
"notifications": "Notifications",
"notificationsPlanWarning": "Task notifications are not available in the Basic plan. Upgrade to Plus to receive reminders when tasks are due or completed.",
"notifyForTask": "Notify for this task",
"notifyForTaskHelp": "When should receive notifications for this task",
"notificationSchedule": "Notification Schedule",
"whoToNotify": "Who to Notify",
"allAssignees": "All Assignees",
"allAssigneesHelp": "Notify all assignees",
"specificGroup": "Specific Group",
"specificGroupHelp": "Notify a specific group",
"telegramGroupIdLabel": "Telegram Group ID:",
"telegramGroupIdPlaceholder": "Telegram Group ID",
"taskSettings": "Task Settings:",
"pointsSystem": "Points System",
"assignPoints": "Assign points for completion",
"assignPointsHelp": "Assign points to this task and user will earn points when they completed it",
"pointsLabel": "Points:",
"pointsPlaceholder": "Points",
"approvalRequirement": "Approval Requirement",
"requireApproval": "Require admin approval",
"requireApprovalHelp": "This task will need approval from an admin before being marked as complete",
"privacySettings": "Privacy Settings",
"privacyDesc": "Who can see this task?",
"public": "Public",
"publicHelp": "Everyone in your circle",
"limited": "Limited",
"limitedHelp": "You and others that are assigned to the task",
"limitedDisabledHint": " (No assignees selected, Limited option is disabled)",
"createdBy": "Created by",
"updatedBy": "Updated by",
"archive": "Archive",
"unarchive": "Unarchive",
"create": "Create"
},
"addTask": {
"title": "Create new task",
"defaultProject": "Default Project",
"anyone": "Anyone",
"taskPlaceholder": "Type your task...",
"descriptionChip": "Description",
"subtasksChip": "Subtasks",
"creating": "Creating…",
"createCount": "Create {{count}} Tasks",
"useTask": "Use Task",
"processing": "Processing",
"create": "Create"
}
}

View File

@@ -243,5 +243,90 @@
"cancelAll": "Cancel All"
},
"attachments": "Attachments",
"fileNumbered": "File {{index}}"
"fileNumbered": "File {{index}}",
"onboarding": {
"sources": {
"speak": "Speak",
"snap": "Snap",
"type": "Type"
},
"capture": {
"voiceCaption": "Dates, labels and points from your words",
"deviceCaption": "Read on your device",
"typeCaption": "#labels @people *points as you type",
"trashTitle": "♻️ Take out the trash",
"trashDue": "Due tomorrow",
"trashRepeat": "Every Monday",
"homeLabel": "Home",
"vehicleTitle": "🚗 Vehicle Registration Renewal",
"vehicleDue": "Due Aug 3",
"acTitle": "💨 Change the AC filter",
"acDue": "Due Fri",
"acRepeat": "Every 3 months"
},
"schedule": {
"fromDueDate": "Reschedule from due date",
"fromCompletionDate": "Reschedule from completion date",
"due": "Due",
"done": "Done",
"next": "Next"
},
"nfc": {
"tagOnWasher": "Tag on the washer",
"tapToOpen": "Tap to open",
"swapFilter": "Swap the washer filter",
"done": "Done",
"skip": "Skip",
"startTimer": "Start timer",
"waitingApproval": "{{name}} marked it done · waiting on you",
"approve": "Approve",
"reject": "Reject",
"completed": "Completed",
"note": "Used the delicate cycle — filter needs a clean next time.",
"metaJustNow": "Just now · ★ 5 pts",
"metaLastWeek": "Last week · ★ 5 pts"
},
"circle": {
"kitchenTitle": "Kitchen deep clean",
"kitchenDue": "Due Sat",
"kitchenRepeat": "Every week",
"takesTurns": "Takes turns"
},
"turns": {
"binsTitle": "🗑️ Take bins to the curb",
"binsDue": "Due Tomorrow",
"binsRepeat": "Every week",
"completed": "Completed",
"metaJustNow": "Just now · ★ 5 pts"
},
"widget": {
"schoolForms": "School forms",
"takeOutTrash": "Take out the trash",
"waterPlants": "Water the plants",
"today": "Today",
"left": "{{count}} left"
},
"reminders": {
"trashTitle": "Take out the trash",
"trashBody": "Due in 30 minutes · Bin night",
"finishedTitle": "Amalie finished Kitchen deep clean",
"finishedBody": "Your turn is next Saturday"
},
"things": {
"waterBill": "Water bill",
"acFilter": "AC filter",
"trashDay": "Trash day",
"dogMedicine": "Dog's medicine",
"whoseTurn": "Whose turn to cook",
"monthly": "Monthly",
"everyThreeMonths": "Every 3 months",
"everyMonday": "Every Monday",
"daily": "Daily",
"weekly": "Weekly",
"tagBills": "Bills",
"tagHome": "Home",
"tagPets": "Pets",
"tagCooking": "Cooking"
}
}
}

View File

@@ -168,7 +168,8 @@
"joinCircle": "Join Circle",
"joinedPending": "Joined circle successfully, wait for the circle owner to accept your request.",
"alreadyMember": "You are already a member of this circle",
"joinFailed": "Failed to join circle"
"joinFailed": "Failed to join circle",
"copyLink": "Copy Link"
},
"accountSettings": {
"title": "Account Settings",
@@ -423,7 +424,8 @@
"name": "Weekly Goals",
"description": "Shows weekly progress and family completion stats"
}
}
},
"detailedDescription": "Customize the layout and visibility of cards in the sidepanel. the section only available on large screen devices such as tablets and desktops.."
},
"theme": {
"title": "Theme Preferences",
@@ -457,7 +459,8 @@
"ymd": "YYYY-MM-DD (ISO)",
"long": "Long format (e.g., January 1, 2024)",
"short": "Short format (e.g., Jan 1, 2024)"
}
},
"descriptionLong": "Customize language, date format, and regional preferences for your account. These settings will apply throughout the application."
},
"advanced": {
"title": "Advanced Settings",

View File

@@ -37,6 +37,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import DurationInput from '../../components/common/DurationInput'
@@ -94,6 +95,7 @@ const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once']
const NO_DUE_DATE_ALLOWED_TYPE = ['trigger']
const ChoreEdit = () => {
const { t } = useTranslation('chores')
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
@@ -207,16 +209,18 @@ const ChoreEdit = () => {
const errors = {}
if (name.trim() === '') {
errors.name = 'Name is required'
errors.name = t('choreEdit.errNameRequired')
}
if (frequencyType === 'interval' && !frequency > 0) {
errors.frequency = `Invalid frequency, the ${frequencyMetadata.unit} should be > 0`
errors.frequency = t('choreEdit.errFrequencyInvalid', {
unit: frequencyMetadata.unit,
})
}
if (
frequencyType === 'days_of_the_week' &&
frequencyMetadata['days']?.length === 0
) {
errors.frequency = 'Please select at least one day of the week'
errors.frequency = t('choreEdit.errSelectDayOfWeek')
}
// Validate advanced scheduling patterns
@@ -226,14 +230,13 @@ const ChoreEdit = () => {
(!frequencyMetadata?.occurrences ||
frequencyMetadata.occurrences.length === 0)
) {
errors.frequency =
'Please select at least one day occurrence for the month'
errors.frequency = t('choreEdit.errSelectDayOccurrence')
}
if (
frequencyType === 'day_of_the_month' &&
frequencyMetadata['months']?.length === 0
) {
errors.frequency = 'Please select at least one month'
errors.frequency = t('choreEdit.errSelectMonth')
}
if (
dueDate === null &&
@@ -243,14 +246,14 @@ const ChoreEdit = () => {
if (REPEAT_ON_TYPE.includes(frequencyType)) {
console.log('VALIDATION:', dueDate, frequencyType)
errors.dueDate = 'Start date is required'
errors.dueDate = t('choreEdit.errStartDateRequired')
} else {
errors.dueDate = 'Due date is required'
errors.dueDate = t('choreEdit.errDueDateRequired')
}
}
if (frequencyType === 'trigger') {
if (!isThingValid) {
errors.thingTrigger = 'Thing trigger is invalid'
errors.thingTrigger = t('choreEdit.errThingTrigger')
}
}
@@ -263,7 +266,7 @@ const ChoreEdit = () => {
<ListItem key={key}>{errors[key]}</ListItem>
))
showError({
title: 'Please resolve the following errors:',
title: t('choreEdit.errTitle'),
message: <List>{errorList}</List>,
})
return false
@@ -417,13 +420,13 @@ const ChoreEdit = () => {
result?.res?._pendingCreate
) {
showSuccess({
title: 'Saved Offline',
message: 'Your changes will sync when you are back online.',
title: t('choreEdit.savedOfflineTitle'),
message: t('choreEdit.savedOfflineMessage'),
})
} else {
showSuccess({
title: 'Chore Saved',
message: 'Your task has been saved successfully!',
title: t('choreEdit.savedTitle'),
message: t('choreEdit.savedMessage'),
})
}
Navigate('/chores')
@@ -431,10 +434,10 @@ const ChoreEdit = () => {
.catch(error => {
console.error('Failed to save chore:', error)
showError({
title: 'Save Failed',
title: t('choreEdit.saveFailedTitle'),
message: error?.isServerMessage
? error.message
: 'Failed to save chore, please try again.',
: t('choreEdit.saveFailedMessage'),
})
})
}
@@ -693,8 +696,8 @@ const ChoreEdit = () => {
})
if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
title: t('choreEdit.uploadFailedTitle'),
message: t('choreEdit.uploadFailedMessage'),
})
return
}
@@ -710,8 +713,8 @@ const ChoreEdit = () => {
])
} catch {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
title: t('choreEdit.uploadFailedTitle'),
message: t('choreEdit.uploadFailedMessage'),
})
} finally {
setIsUploadingAttachment(false)
@@ -725,16 +728,16 @@ const ChoreEdit = () => {
if (cancelled) return
if (error || !image) {
showError({
title: 'Scan Failed',
message: error || 'Could not scan the document.',
title: t('choreEdit.scanFailedTitle'),
message: error || t('choreEdit.scanFailedMessage'),
})
return
}
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
if (!file) {
showError({
title: 'Scan Failed',
message: 'Could not read the scanned image.',
title: t('choreEdit.scanFailedTitle'),
message: t('choreEdit.scanReadFailedMessage'),
})
return
}
@@ -744,10 +747,10 @@ const ChoreEdit = () => {
const handleDelete = () => {
setConfirmModelConfig({
isOpen: true,
title: 'Delete Chore',
confirmText: 'Delete',
cancelText: 'Cancel',
message: 'Are you sure you want to delete this chore?',
title: t('choreEdit.deleteChoreTitle'),
confirmText: t('common:delete'),
cancelText: t('common:cancel'),
message: t('edit.deleteConfirm'),
onClose: isConfirmed => {
if (isConfirmed === true) {
deleteChores.mutate([choreId], {
@@ -756,8 +759,10 @@ const ChoreEdit = () => {
},
onError: error => {
showError({
title: 'Delete Failed',
message: `Failed to delete chore: ${error.message}`,
title: t('choreEdit.deleteFailedTitle'),
message: t('choreEdit.deleteChoreFailed', {
error: error.message,
}),
})
},
})
@@ -789,10 +794,8 @@ const ChoreEdit = () => {
<Box mb={3}>
<FormControl error={errors.name}>
<Typography level='h4'>Name</Typography>
<Typography level='body-md'>
What is the name of this task?
</Typography>
<Typography level='h4'>{t('choreEdit.name')}</Typography>
<Typography level='body-md'>{t('choreEdit.nameDesc')}</Typography>
<Input value={name} onChange={e => setName(e.target.value)} />
<FormHelperText error>{errors.name}</FormHelperText>
</FormControl>
@@ -800,8 +803,10 @@ const ChoreEdit = () => {
<Box mb={3}>
<FormControl error={errors.description}>
<Typography level='h4'>Description</Typography>
<Typography level='body-md'>What is this task about?</Typography>
<Typography level='h4'>{t('choreEdit.description')}</Typography>
<Typography level='body-md'>
{t('choreEdit.descriptionDesc')}
</Typography>
<RichTextEditor
value={description}
onChange={setDescription}
@@ -814,8 +819,8 @@ const ChoreEdit = () => {
</Box>
<Box mb={3}>
<Typography level='h4'>Priority</Typography>
<Typography level='body-md'>How important is this task?</Typography>
<Typography level='h4'>{t('choreEdit.priority')}</Typography>
<Typography level='body-md'>{t('choreEdit.priorityDesc')}</Typography>
{/* Priority Chip Selection */}
<Box
@@ -861,7 +866,7 @@ const ChoreEdit = () => {
minHeight: 34,
}}
>
No Priority
{t('choreEdit.noPriority')}
</Chip>
</Box>
</Box>
@@ -869,9 +874,9 @@ const ChoreEdit = () => {
{/* Project Selection - Show only if there are multiple projects */}
{projects.length >= 1 && (
<Box mb={3}>
<Typography level='h4'>Project</Typography>
<Typography level='h4'>{t('choreEdit.project')}</Typography>
<Typography level='body-md'>
Which project does this task belong to?
{t('choreEdit.projectDesc')}
</Typography>
<Select
value={projectId}
@@ -906,7 +911,7 @@ const ChoreEdit = () => {
)
})()}
</Avatar>
Default Project
{t('choreEdit.defaultProject')}
</Box>
</Option>
{projects.map(project => (
@@ -947,10 +952,8 @@ const ChoreEdit = () => {
)}
<Box mb={3}>
<Typography level='h4'>Labels</Typography>
<Typography level='body-md'>
Things to remember about this task or to tag it
</Typography>
<Typography level='h4'>{t('choreEdit.labels')}</Typography>
<Typography level='body-md'>{t('choreEdit.labelsDesc')}</Typography>
<Select
multiple
onChange={(event, newValue) => {
@@ -1010,13 +1013,13 @@ const ChoreEdit = () => {
}}
>
<Add />
Add New Label
{t('choreEdit.addNewLabel')}
</MenuItem>
</Select>
</Box>
<Box>
<Typography level='h4'>Sub Tasks</Typography>
<Typography level='h4'>{t('choreEdit.subTasks')}</Typography>
{/* <FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
@@ -1049,8 +1052,10 @@ const ChoreEdit = () => {
</Box>
<Box mt={3}>
<Typography level='h4'>Attachments</Typography>
<Typography level='body-md'>Files attached to this task</Typography>
<Typography level='h4'>{t('choreEdit.attachments')}</Typography>
<Typography level='body-md'>
{t('choreEdit.attachmentsDesc')}
</Typography>
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
{attachments.length > 0 && (
<Box
@@ -1140,8 +1145,8 @@ const ChoreEdit = () => {
})
.catch(() => {
showError({
title: 'Delete Failed',
message: 'Failed to delete attachment.',
title: t('choreEdit.deleteFailedTitle'),
message: t('choreEdit.deleteAttachmentFailed'),
})
})
}}
@@ -1166,8 +1171,8 @@ const ChoreEdit = () => {
})
.catch(() => {
showError({
title: 'Delete Failed',
message: 'Failed to delete attachment.',
title: t('choreEdit.deleteFailedTitle'),
message: t('choreEdit.deleteAttachmentFailed'),
})
})
}}
@@ -1188,7 +1193,7 @@ const ChoreEdit = () => {
startDecorator={isUploadingAttachment ? null : <UploadFile />}
loading={isUploadingAttachment}
>
Upload File
{t('choreEdit.uploadFile')}
<input
type='file'
hidden
@@ -1208,7 +1213,7 @@ const ChoreEdit = () => {
disabled={isUploadingAttachment}
onClick={handleScanAttachment}
>
Scan
{t('choreEdit.scan')}
</Button>
)}
</Box>
@@ -1219,8 +1224,10 @@ const ChoreEdit = () => {
{/* Section 2: Assignment & Responsibility */}
<Box mb={4}>
<Box mb={3}>
<Typography level='h4'>Assignees</Typography>
<Typography level='body-md'>Who can do this task?</Typography>
<Typography level='h4'>{t('choreEdit.assignees')}</Typography>
<Typography level='body-md'>
{t('choreEdit.assigneesDesc')}
</Typography>
<Card>
<List
orientation='horizontal'
@@ -1242,7 +1249,7 @@ const ChoreEdit = () => {
overlay
disableIcon
variant='soft'
label='Anyone'
label={t('choreEdit.anyone')}
/>
</ListItem>
@@ -1308,7 +1315,7 @@ const ChoreEdit = () => {
setShowSaveAssigneeDefault(false)
}}
>
Remember for Future Tasks
{t('choreEdit.rememberFuture')}
</Button>
</Box>
)}
@@ -1323,15 +1330,17 @@ const ChoreEdit = () => {
assignStrategyValue === 'no_assignee' ? 'none' : 'block',
}}
>
<Typography level='h4'>Currently Assigned To</Typography>
<Typography level='h4'>
{t('choreEdit.currentlyAssigned')}
</Typography>
<Typography level='body-md'>
Who is assigned the next due?
{t('choreEdit.currentlyAssignedDesc')}
</Typography>
<Select
placeholder={
assignees.length === 0
? 'No Assignees yet can perform this task'
: 'Select an assignee for this task'
? t('choreEdit.noAssigneesPlaceholder')
: t('choreEdit.selectAssigneePlaceholder')
}
disabled={assignees.length === 0}
value={assignedToValue}
@@ -1348,9 +1357,11 @@ const ChoreEdit = () => {
</Box>
<Box>
<Typography level='h4'>Assignment Strategy</Typography>
<Typography level='h4'>
{t('choreEdit.assignStrategy')}
</Typography>
<Typography level='body-md'>
How to pick the next assignee for the following task?
{t('choreEdit.assignStrategyDesc')}
</Typography>
<Card>
<List
@@ -1369,10 +1380,7 @@ const ChoreEdit = () => {
overlay
disableIcon
variant='soft'
label={item
.split('_')
.map(x => x.charAt(0).toUpperCase() + x.slice(1))
.join(' ')}
label={t(`choreEdit.strategy.${item}`)}
/>
</ListItem>
))}
@@ -1412,11 +1420,13 @@ const ChoreEdit = () => {
<Box mt={3} mb={3}>
<Typography level='h4'>
{REPEAT_ON_TYPE.includes(frequencyType) ? 'Start Date' : 'Due Date'}
{REPEAT_ON_TYPE.includes(frequencyType)
? t('choreEdit.startDate')
: t('choreEdit.dueDate')}
</Typography>
{frequencyType === 'trigger' && !dueDate && (
<Typography level='body-sm'>
Due Date will be set when the trigger of the thing is met
{t('choreEdit.triggerDueHint')}
</Typography>
)}
@@ -1442,11 +1452,9 @@ const ChoreEdit = () => {
defaultChecked={dueDate !== null}
checked={dueDate !== null}
overlay
label='Give this task a due date'
label={t('choreEdit.giveDueDate')}
/>
<FormHelperText>
Task needs to be completed by a specific time
</FormHelperText>
<FormHelperText>{t('choreEdit.giveDueDateHelp')}</FormHelperText>
</FormControl>
)}
{dueDate && (
@@ -1454,8 +1462,8 @@ const ChoreEdit = () => {
<FormControl error={Boolean(errors.dueDate)} sx={{ mt: 2 }}>
<Typography level='body-md'>
{REPEAT_ON_TYPE.includes(frequencyType)
? 'When does this task start?'
: 'When is the next first time this task is due?'}
? t('choreEdit.startWhen')
: t('choreEdit.dueWhen')}
</Typography>
<Input
type='date'
@@ -1471,19 +1479,19 @@ const ChoreEdit = () => {
checked={useCustomTime}
onChange={e => handleUseCustomTimeChange(e.target.checked)}
overlay
label='Set a specific time'
label={t('choreEdit.setSpecificTime')}
/>
<FormHelperText>
{useCustomTime
? 'Task will be due at the specified time'
: 'Task will be due at the end of the day (11:59 PM)'}
? t('choreEdit.dueAtSpecifiedTime')
: t('choreEdit.dueEndOfDay')}
</FormHelperText>
</FormControl>
{useCustomTime && (
<Box sx={{ mt: 2, ml: 4 }}>
<Typography level='body-sm' mb={1}>
Time:
{t('choreEdit.timeLabel')}
</Typography>
<Input
type='time'
@@ -1499,9 +1507,9 @@ const ChoreEdit = () => {
{dueDate && (
<Box mb={3}>
<Typography level='h4'>Task Window</Typography>
<Typography level='h4'>{t('choreEdit.taskWindow')}</Typography>
<Typography level='body-md'>
Define when this task can be completed and when it expires
{t('choreEdit.taskWindowDesc')}
</Typography>
{/* Available From (Completion Window) */}
@@ -1516,10 +1524,10 @@ const ChoreEdit = () => {
}
}}
overlay
label='Set earliest completion time'
label={t('choreEdit.earliestCompletion')}
/>
<FormHelperText>
Task becomes available to complete X hours before the due date
{t('choreEdit.earliestCompletionHelp')}
</FormHelperText>
</FormControl>
@@ -1531,7 +1539,9 @@ const ChoreEdit = () => {
ml: 4,
}}
>
<Typography level='body-sm'>Hours:</Typography>
<Typography level='body-sm'>
{t('choreEdit.hoursLabel')}
</Typography>
<Input
type='number'
value={completionWindow}
@@ -1542,7 +1552,7 @@ const ChoreEdit = () => {
max: 24 * 7,
},
}}
placeholder='Hours'
placeholder={t('choreEdit.hoursPlaceholder')}
onChange={e => {
setCompletionWindow(parseInt(e.target.value))
}}
@@ -1589,7 +1599,9 @@ const ChoreEdit = () => {
size='sm'
minValue={0}
/>
<Typography level='body-sm'>after due date</Typography>
<Typography level='body-sm'>
{t('choreEdit.afterDueDate')}
</Typography>
</Box>
)}
</Box>
@@ -1597,9 +1609,9 @@ const ChoreEdit = () => {
{!['once', 'no_repeat'].includes(frequencyType) && (
<Box>
<Typography level='h4'>Scheduling Preferences</Typography>
<Typography level='h4'>{t('choreEdit.schedulingPrefs')}</Typography>
<Typography level='body-md'>
How to reschedule the next due date?
{t('choreEdit.schedulingPrefsDesc')}
</Typography>
<RadioGroup name='tiers' sx={{ gap: 1, '& > div': { p: 1 } }}>
<FormControl>
@@ -1607,11 +1619,10 @@ const ChoreEdit = () => {
overlay
checked={!isRolling}
onClick={() => setIsRolling(false)}
label='Reschedule from due date'
label={t('choreEdit.rescheduleFromDue')}
/>
<FormHelperText>
the next task will be scheduled from the original due date,
even if the previous task was completed late
{t('choreEdit.rescheduleFromDueHelp')}
</FormHelperText>
</FormControl>
<FormControl>
@@ -1622,11 +1633,10 @@ const ChoreEdit = () => {
setIsRolling(true)
setDeadlineOffset(-1)
}}
label='Reschedule from completion date'
label={t('choreEdit.rescheduleFromCompletion')}
/>
<FormHelperText>
the next task will be scheduled from the actual completion
date of the previous task
{t('choreEdit.rescheduleFromCompletionHelp')}
</FormHelperText>
</FormControl>
</RadioGroup>
@@ -1635,11 +1645,10 @@ const ChoreEdit = () => {
{/* Section 3.1: Notifications */}
<Box mb={3}>
<Typography level='h4'>Notifications</Typography>
<Typography level='h4'>{t('choreEdit.notifications')}</Typography>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mb: 1 }}>
Task notifications are not available in the Basic plan. Upgrade to
Plus to receive reminders when tasks are due or completed.
{t('choreEdit.notificationsPlanWarning')}
</Typography>
)}
@@ -1655,14 +1664,14 @@ const ChoreEdit = () => {
checked={isNotificable}
disabled={!isPlusAccount(userProfile)}
overlay
label='Notify for this task'
label={t('choreEdit.notifyForTask')}
/>
<FormHelperText
sx={{
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
}}
>
When should receive notifications for this task
{t('choreEdit.notifyForTaskHelp')}
</FormHelperText>
</FormControl>
</Box>
@@ -1677,7 +1686,7 @@ const ChoreEdit = () => {
>
<Card variant='outlined'>
<Typography level='h4' mb={2}>
Notification Schedule
{t('choreEdit.notificationSchedule')}
</Typography>
<Box sx={{ p: 0.5 }}>
<NotificationTemplate
@@ -1695,16 +1704,18 @@ const ChoreEdit = () => {
</Box>
<Typography level='h4' mt={3} mb={2}>
Who to Notify
{t('choreEdit.whoToNotify')}
</Typography>
<FormControl>
<Checkbox
overlay
disabled={true}
checked={true}
label='All Assignees'
label={t('choreEdit.allAssignees')}
/>
<FormHelperText>Notify all assignees</FormHelperText>
<FormHelperText>
{t('choreEdit.allAssigneesHelp')}
</FormHelperText>
</FormControl>
<FormControl>
@@ -1725,9 +1736,11 @@ const ChoreEdit = () => {
? notificationMetadata?.circleGroup
: false
}
label='Specific Group'
label={t('choreEdit.specificGroup')}
/>
<FormHelperText>Notify a specific group</FormHelperText>
<FormHelperText>
{t('choreEdit.specificGroupHelp')}
</FormHelperText>
</FormControl>
{notificationMetadata?.circleGroup && (
@@ -1737,11 +1750,13 @@ const ChoreEdit = () => {
ml: 4,
}}
>
<Typography level='body-sm'>Telegram Group ID:</Typography>
<Typography level='body-sm'>
{t('choreEdit.telegramGroupIdLabel')}
</Typography>
<Input
type='number'
value={notificationMetadata?.circleGroupID}
placeholder='Telegram Group ID'
placeholder={t('choreEdit.telegramGroupIdPlaceholder')}
onChange={e => {
setNotificationMetadata({
...notificationMetadata,
@@ -1766,11 +1781,11 @@ const ChoreEdit = () => {
pb: 1,
}}
>
Task Settings:
{t('choreEdit.taskSettings')}
</Typography>
<Box mb={3}>
<Typography level='h4'>Points System</Typography>
<Typography level='h4'>{t('choreEdit.pointsSystem')}</Typography>
<FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
@@ -1782,12 +1797,9 @@ const ChoreEdit = () => {
}}
checked={points > -1}
overlay
label='Assign points for completion'
label={t('choreEdit.assignPoints')}
/>
<FormHelperText>
Assign points to this task and user will earn points when they
completed it
</FormHelperText>
<FormHelperText>{t('choreEdit.assignPointsHelp')}</FormHelperText>
</FormControl>
{points != -1 && (
<Card variant='outlined' sx={{ mt: 2 }}>
@@ -1797,7 +1809,9 @@ const ChoreEdit = () => {
ml: 4,
}}
>
<Typography level='body-sm'>Points:</Typography>
<Typography level='body-sm'>
{t('choreEdit.pointsLabel')}
</Typography>
<Input
type='number'
value={points}
@@ -1808,7 +1822,7 @@ const ChoreEdit = () => {
max: 1000,
},
}}
placeholder='Points'
placeholder={t('choreEdit.pointsPlaceholder')}
onChange={e => {
setPoints(parseInt(e.target.value))
}}
@@ -1819,7 +1833,9 @@ const ChoreEdit = () => {
</Box>
<Box mb={3}>
<Typography level='h4'>Approval Requirement</Typography>
<Typography level='h4'>
{t('choreEdit.approvalRequirement')}
</Typography>
<FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
@@ -1827,18 +1843,17 @@ const ChoreEdit = () => {
}}
checked={requireApproval}
overlay
label='Require admin approval'
label={t('choreEdit.requireApproval')}
/>
<FormHelperText>
This task will need approval from an admin before being marked as
complete
{t('choreEdit.requireApprovalHelp')}
</FormHelperText>
</FormControl>
</Box>
<Box>
<Typography level='h4'>Privacy Settings</Typography>
<Typography level='body-md'>Who can see this task?</Typography>
<Typography level='h4'>{t('choreEdit.privacySettings')}</Typography>
<Typography level='body-md'>{t('choreEdit.privacyDesc')}</Typography>
<RadioGroup
name='isPrivate'
value={isPrivate}
@@ -1852,20 +1867,20 @@ const ChoreEdit = () => {
}}
>
<FormControl>
<Radio overlay value={false} label='Public' />
<FormHelperText>Everyone in your circle</FormHelperText>
<Radio overlay value={false} label={t('choreEdit.public')} />
<FormHelperText>{t('choreEdit.publicHelp')}</FormHelperText>
</FormControl>
<FormControl>
<Radio
overlay
disabled={anyone || assignableTo.length === 0}
value={true}
label='Limited'
label={t('choreEdit.limited')}
/>
<FormHelperText>
You and others that are assigned to the task
{t('choreEdit.limitedHelp')}
{anyone || assignableTo.length === 0
? ' (No assignees selected, Limited option is disabled)'
? t('choreEdit.limitedDisabledHint')
: ''}
</FormHelperText>
</FormControl>
@@ -1893,7 +1908,7 @@ const ChoreEdit = () => {
setShowSavePrivacyDefault(false)
}}
>
Remember for Future Tasks
{t('choreEdit.rememberFuture')}
</Button>
</Box>
)}
@@ -1910,7 +1925,7 @@ const ChoreEdit = () => {
}}
>
<Typography level='body1'>
Created by{' '}
{t('choreEdit.createdBy')}{' '}
<Chip variant='solid'>
{membersData.res.find(f => f.userId === createdBy)?.displayName}
</Chip>{' '}
@@ -1921,7 +1936,7 @@ const ChoreEdit = () => {
<Divider sx={{ my: 1 }} />
<Typography level='body1'>
Updated by{' '}
{t('choreEdit.updatedBy')}{' '}
<Chip variant='solid'>
{
membersData.res.find(f => f.userId === updatedBy)
@@ -1971,7 +1986,7 @@ const ChoreEdit = () => {
: unarchiveChore.mutate(choreId)
}}
>
{isActive ? 'Archive' : 'Unarchive'}
{isActive ? t('choreEdit.archive') : t('choreEdit.unarchive')}
</Button>
<MenuButton
slots={{ root: IconButton }}
@@ -1987,7 +2002,7 @@ const ChoreEdit = () => {
</ButtonGroup>
<Menu placement='top-end'>
<MenuItem color='danger' onClick={handleDelete}>
Delete
{t('common:delete')}
</MenuItem>
</Menu>
</Dropdown>
@@ -1999,13 +2014,13 @@ const ChoreEdit = () => {
window.history.back()
}}
>
Cancel
{t('common:cancel')}
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Esc' sx={{ ml: 1 }} />
)}
</Button>
<Button color='primary' variant='solid' onClick={HandleSaveChore}>
{choreId > 0 ? 'Save' : 'Create'}
{choreId > 0 ? t('common:save') : t('choreEdit.create')}
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ import { Purchases } from '@revenuecat/purchases-capacitor'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import RealTimeSettings from '../../components/RealTimeSettings'
@@ -57,6 +58,7 @@ import StorageSettings from './StorageSettings'
import ThemeToggle from './ThemeToggle'
const Settings = () => {
const { t } = useTranslation('settings')
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
@@ -83,8 +85,8 @@ const Settings = () => {
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText = t('common.confirm'),
cancelText = t('common.cancel'),
color = 'primary',
) => {
setConfirmModalConfig({
@@ -112,7 +114,7 @@ const Settings = () => {
} catch (error) {
showNotification({
type: 'error',
message: 'Failed to refresh member requests',
message: t('circleSettings.refreshFailed'),
})
} finally {
setIsRefreshing(false)
@@ -195,27 +197,29 @@ const Settings = () => {
const getSubscriptionDetails = () => {
if (userProfile?.subscription === 'active') {
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${fmt.date(
userProfile?.expiration,
)}.`
return t('accountSettings.activeDescription', {
date: fmt.date(userProfile?.expiration),
})
} else if (userProfile?.subscription === 'cancelled') {
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${fmt.date(
userProfile?.expiration,
)}.`
return t('accountSettings.cancelledDescription', {
date: fmt.date(userProfile?.expiration),
})
} else {
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
return t('accountSettings.freeDescription')
}
}
const getSubscriptionStatus = () => {
if (userProfile?.subscription === 'active') {
return `Plus`
return t('accountSettings.plus')
} else if (userProfile?.subscription === 'cancelled') {
if (moment().isBefore(userProfile?.expiration)) {
return `Plus(until ${fmt.date(userProfile?.expiration)})`
return t('accountSettings.plusUntil', {
date: fmt.date(userProfile?.expiration),
})
}
return `Free`
return t('accountSettings.free')
} else {
return `Free`
return t('accountSettings.free')
}
}
@@ -241,18 +245,15 @@ const Settings = () => {
<Container>
<ProfileSettings />
<div className='grid gap-4 py-4' id='circle'>
<Typography level='h3'>Circle settings</Typography>
<Typography level='h3'>{t('circleSettings.title')}</Typography>
<Divider />
<Typography level='body-md'>
Your account is automatically connected to a Circle when you create or
join one. Easily invite friends by sharing the unique Circle code or
link below. You'll receive a notification below when someone requests
to join your Circle.
{t('circleSettings.description')}
</Typography>
<Typography level='title-sm' mb={-1}>
{userCircles[0]?.userRole === 'member'
? `You part of ${userCircles[0]?.name} `
: `You circle code is:`}
? t('circleSettings.memberOf', { name: userCircles[0]?.name })
: t('circleSettings.yourCircleCode')}
<Input
value={userCircles[0]?.invite_code}
@@ -269,11 +270,11 @@ const Settings = () => {
navigator.clipboard.writeText(userCircles[0]?.invite_code)
showNotification({
type: 'success',
message: 'Code copied to clipboard',
message: t('circleSettings.codeCopied'),
})
}}
>
Copy Code
{t('circleSettings.copyCode')}
</Button>
<Button
variant='soft'
@@ -287,11 +288,11 @@ const Settings = () => {
)
showNotification({
type: 'success',
message: 'Link copied to clipboard',
message: t('circleSettings.linkCopied'),
})
}}
>
Copy Link
{t('circleSettings.copyLink')}
</Button>
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
<Button
@@ -300,35 +301,37 @@ const Settings = () => {
sx={{ ml: 1 }}
onClick={() => {
showConfirmation(
'Are you sure you want to leave your circle?',
'Leave Circle',
t('circleSettings.leaveConfirmMessage'),
t('circleSettings.leaveConfirmTitle'),
() => {
LeaveCircle(userCircles[0]?.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Left circle successfully',
message: t('circleSettings.leftCircle'),
})
} else {
showNotification({
type: 'error',
message: 'Failed to leave circle',
message: t('circleSettings.leaveFailed'),
})
}
})
},
'Leave',
'Cancel',
t('circleSettings.leaveConfirmButton'),
t('common.cancel'),
'danger',
)
}}
>
Leave Circle
{t('circleSettings.leave')}
</Button>
)}
</Typography>
<Typography level='title-md'>Circle Members</Typography>
<Typography level='title-md'>
{t('circleSettings.circleMembers')}
</Typography>
{circleMembers.map(member => (
<Card key={member.id} className='p-4'>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
@@ -336,19 +339,27 @@ const Settings = () => {
<Typography level='body-md'>
{member.displayName.charAt(0).toUpperCase() +
member.displayName.slice(1)}
{member.userId === userProfile.id ? '(You)' : ''}{' '}
{member.userId === userProfile.id
? t('circleSettings.you')
: ''}{' '}
<Chip>
{' '}
{member.isActive ? member.role : 'Pending Approval'}
{member.isActive
? member.role
: t('circleSettings.pendingApproval')}
</Chip>
</Typography>
{member.isActive ? (
<Typography level='body-sm'>
Joined on {fmt.date(member.createdAt)}
{t('circleSettings.joinedOn', {
date: fmt.date(member.createdAt),
})}
</Typography>
) : (
<Typography level='body-sm' color='danger'>
Request to join {fmt.date(member.updatedAt)}
{t('circleSettings.requestedToJoin', {
date: fmt.date(member.updatedAt),
})}
</Typography>
)}
</Box>
@@ -378,28 +389,14 @@ const Settings = () => {
} else {
showNotification({
type: 'error',
message: 'Failed to update role',
message: t('circleSettings.roleUpdateFailed'),
})
}
})
}}
>
{[
{
value: 'member',
description: 'Just a regular member of the circle',
},
{
value: 'manager',
description:
'Can impersonate users and perform actions on their behalf',
},
{
value: 'admin',
description: 'Full access to the circle',
},
].map((option, index) => (
<Option value={option.value} key={index}>
{['member', 'manager', 'admin'].map((option, index) => (
<Option value={option} key={index}>
<Box
sx={{
display: 'flex',
@@ -414,14 +411,13 @@ const Settings = () => {
level='title-sm'
sx={{ mb: 0, mt: 0, lineHeight: 1.1 }}
>
{option.value.charAt(0).toUpperCase() +
option.value.slice(1)}
{t(`circleSettings.roles.${option}`)}
</Typography>
<Typography
level='body-sm'
sx={{ mt: 0, mb: 0, lineHeight: 1.1 }}
>
{option.description}
{t(`circleSettings.roles.${option}Description`)}
</Typography>
</Box>
</Option>
@@ -437,8 +433,10 @@ const Settings = () => {
size='sm'
onClick={() => {
showConfirmation(
`Are you sure you want to remove ${member.displayName} from your circle?`,
'Remove Member',
t('circleSettings.removeMemberMessage', {
name: member.displayName,
}),
t('circleSettings.removeMemberTitle'),
() => {
DeleteCircleMember(
member.circleId,
@@ -447,7 +445,7 @@ const Settings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Removed member successfully',
message: t('circleSettings.memberRemoved'),
})
// Invalidate and refetch circle-related queries
queryClient.invalidateQueries(['circleMembers'])
@@ -463,8 +461,8 @@ const Settings = () => {
}
})
},
'Remove',
'Cancel',
t('common.remove'),
t('common.cancel'),
'danger',
)
}}
@@ -485,11 +483,15 @@ const Settings = () => {
mb: 1,
}}
>
<Typography level='title-md'>Circle Member Requests</Typography>
<Typography level='title-md'>
{t('circleSettings.circleMemberRequests')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{lastRefresh && (
<Typography level='body-sm' color='neutral'>
Last updated: {fmt.dateTime(lastRefresh)}
{t('circleSettings.lastUpdated', {
time: fmt.dateTime(lastRefresh),
})}
</Typography>
)}
<Button
@@ -501,7 +503,9 @@ const Settings = () => {
isRefreshing ? <CircularProgress size='sm' /> : <Refresh />
}
>
{isRefreshing ? 'Refreshing...' : 'Refresh'}
{isRefreshing
? t('circleSettings.refreshing')
: t('common.refresh')}
</Button>
</Box>
</Box>
@@ -509,21 +513,24 @@ const Settings = () => {
{circleMemberRequests.map(request => (
<Card key={request.id} className='p-4'>
<Typography level='body-md'>
{request.displayName} wants to join your circle.
{t('circleSettings.wantsToJoin', { name: request.displayName })}
</Typography>
<Button
variant='soft'
color='success'
onClick={() => {
showConfirmation(
`Are you sure you want to accept ${request.displayName} (username: ${request.username}) to join your circle?`,
'Accept Member Request',
t('circleSettings.acceptRequestMessage', {
name: request.displayName,
username: request.username,
}),
t('circleSettings.acceptRequestTitle'),
() => {
AcceptCircleMemberRequest(request.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Accepted request successfully',
message: t('circleSettings.requestAccepted'),
})
// Invalidate and refetch circle-related queries
queryClient.invalidateQueries(['circleMembers'])
@@ -540,26 +547,25 @@ const Settings = () => {
}
})
},
'Accept',
'Cancel',
t('circleSettings.accept'),
t('common.cancel'),
)
}}
>
Accept
{t('circleSettings.accept')}
</Button>
</Card>
))}
<Divider> or </Divider>
<Divider> {t('circleSettings.or')} </Divider>
<Typography level='body-md'>
if want to join someone else's Circle? Ask them for their unique
Circle code or join link. Enter the code below to join their Circle.
{t('circleSettings.joinOtherDescription')}
</Typography>
<Typography level='title-sm' mb={-1}>
Enter Circle code:
{t('circleSettings.enterCircleCode')}
<Input
placeholder='Enter code'
placeholder={t('circleSettings.enterCodePlaceholder')}
value={circleInviteCode}
onChange={e => setCircleInviteCode(e.target.value)}
size='lg'
@@ -575,20 +581,19 @@ const Settings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message:
'Joined circle successfully, wait for the circle owner to accept your request.',
message: t('circleSettings.joinedPending'),
})
setTimeout(() => navigate('/'), 3000)
} else {
if (resp.status === 409) {
showNotification({
type: 'error',
message: 'You are already a member of this circle',
message: t('circleSettings.alreadyMember'),
})
} else {
showNotification({
type: 'error',
message: 'Failed to join circle',
message: t('circleSettings.joinFailed'),
})
}
setTimeout(() => navigate('/'), 3000)
@@ -596,24 +601,21 @@ const Settings = () => {
})
}}
>
Join Circle
{t('circleSettings.joinCircle')}
</Button>
</Typography>
{circleMembers.find(m => userProfile.id == m.userId)?.role ===
'admin' && (
<>
<Typography level='title-lg' mt={2}>
Webhook
{t('advanced.webhookTitle')}
</Typography>
<Typography level='body-md' mt={-1}>
Webhooks allow you to send real-time notifications to other
services when events happen in your Circle. Configure a webhook
URL to receive real-time updates.
{t('advanced.webhookDescription')}
</Typography>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Webhook notifications are not available in the Basic plan.
Upgrade to Plus to receive real-time updates via webhooks.
{t('advanced.webhookPlusNotice')}
</Typography>
)}
<FormControl sx={{ mt: 1 }}>
@@ -627,7 +629,7 @@ const Settings = () => {
}
}}
variant='soft'
label='Enable Webhook'
label={t('advanced.webhookToggle')}
disabled={!isPlusAccount(userProfile)}
overlay
/>
@@ -636,10 +638,10 @@ const Settings = () => {
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
}}
>
Enable webhook notifications for tasks and things updates.{' '}
{t('advanced.webhookHelper')}{' '}
{userProfile && !isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning'>
Plus Feature
{t('common.plusFeature')}
</Chip>
)}
</FormHelperText>
@@ -647,7 +649,9 @@ const Settings = () => {
{webhookURL !== null && (
<Box>
<Typography level='title-sm'>Webhook URL</Typography>
<Typography level='title-sm'>
{t('advanced.webhookURL')}
</Typography>
<Input
value={webhookURL ? webhookURL : ''}
onChange={e => setWebhookURL(e.target.value)}
@@ -670,19 +674,19 @@ const Settings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Webhook URL updated successfully',
message: t('advanced.webhookUpdated'),
})
} else {
showNotification({
type: 'error',
message: 'Failed to update webhook URL',
message: t('advanced.webhookUpdateFailed'),
})
}
})
}}
disabled={!isPlusAccount(userProfile)}
>
Save
{t('common.save')}
</Button>
</Box>
)}
@@ -695,13 +699,13 @@ const Settings = () => {
</div>
<div className='grid gap-4 py-4' id='account'>
<Typography level='h3'>Account Settings</Typography>
<Typography level='h3'>{t('accountSettings.title')}</Typography>
<Divider />
<Typography level='body-md'>
Change your account settings, type or update your password
{t('accountSettings.description')}
</Typography>
<Typography level='title-md' mb={-1}>
Account Type : {getSubscriptionStatus()}
{t('accountSettings.accountType', { type: getSubscriptionStatus() })}
</Typography>
<Typography level='body-sm'>{getSubscriptionDetails()}</Typography>
<Box>
@@ -733,8 +737,7 @@ const Settings = () => {
queryClient.refetchQueries(['userProfile'])
showNotification({
type: 'success',
message:
'Purchase successful! Please restart the app to access Plus features.',
message: t('accountSettings.purchase.success'),
})
// invalidate user profile to get new subscription status:
}
@@ -749,57 +752,49 @@ const Settings = () => {
// Store problem
showNotification({
type: 'error',
message:
'Store connection issue. Please check your network and try again.',
message: t('accountSettings.purchase.storeConnection'),
})
} else if (error.code === '3') {
// Purchase not allowed
showNotification({
type: 'error',
message:
'Purchases are not allowed on this device. Please check your device restrictions.',
message: t('accountSettings.purchase.notAllowed'),
})
} else if (error.code === '4') {
// Product not available
showNotification({
type: 'error',
message:
'This subscription is not available. Please try again later.',
message: t('accountSettings.purchase.unavailable'),
})
} else if (error.code === '5') {
// Receipt already in use
showNotification({
type: 'error',
message:
'This purchase has already been processed. If you believe this is an error, please contact support.',
message: t('accountSettings.purchase.alreadyProcessed'),
})
} else if (error.code === '6') {
// Missing receipt file
showNotification({
type: 'error',
message:
'Purchase receipt missing. Please try purchasing again.',
message: t('accountSettings.purchase.receiptMissing'),
})
} else if (error.code === '7') {
// Network error
showNotification({
type: 'error',
message:
'Network error. Please check your connection and try again.',
message: t('accountSettings.purchase.networkError'),
})
} else if (error.code === '8') {
// Invalid receipt
showNotification({
type: 'error',
message:
'Invalid purchase receipt. Please contact support if this persists.',
message: t('accountSettings.purchase.invalidReceipt'),
})
} else if (error.code === '9') {
// Payment pending
showNotification({
type: 'warning',
message:
'Payment is pending approval. You will receive access once approved.',
message: t('accountSettings.purchase.pending'),
})
} else {
// Generic error
@@ -808,7 +803,11 @@ const Settings = () => {
console.error('Error occurred in purchase flow')
showNotification({
type: 'error',
message: `Purchase failed: ${error.message || 'Unknown error'}. Please try again or contact support.`,
message: t('accountSettings.purchase.failed', {
error:
error.message ||
t('accountSettings.purchase.unknownError'),
}),
})
}
}
@@ -817,7 +816,7 @@ const Settings = () => {
}
}}
>
Upgrade
{t('accountSettings.upgrade')}
</Button>
{userProfile?.subscription === 'active' && (
@@ -833,14 +832,14 @@ const Settings = () => {
setNativeCancelModal(true)
}}
>
Cancel
{t('accountSettings.cancel')}
</Button>
)}
</Box>
{import.meta.env.VITE_IS_SELF_HOSTED === 'true' && (
<Box>
<Typography level='title-md' mb={1}>
Password :
{t('accountSettings.password')}
</Typography>
<Typography mb={1} level='body-sm'></Typography>
<Button
@@ -849,7 +848,7 @@ const Settings = () => {
setChangePasswordModal(true)
}}
>
Change Password
{t('accountSettings.changePassword')}
</Button>
{changePasswordModal ? (
<PassowrdChangeModal
@@ -860,12 +859,12 @@ const Settings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Password changed successfully',
message: t('accountSettings.passwordChanged'),
})
} else {
showNotification({
type: 'error',
message: 'Password change failed',
message: t('accountSettings.passwordChangeFailed'),
})
}
})
@@ -879,18 +878,17 @@ const Settings = () => {
<Box>
<Typography level='title-md' mb={1} color='danger'>
Danger Zone
{t('accountSettings.dangerZone')}
</Typography>
<Typography level='body-sm' mb={2} color='neutral'>
Once you delete your account, there is no going back. Please be
certain.
{t('accountSettings.dangerZoneDescription')}
</Typography>
<Button
variant='outlined'
color='danger'
onClick={() => setUserDeletionModal(true)}
>
Delete Account
{t('accountSettings.deleteAccount')}
</Button>
</Box>
</div>
@@ -899,32 +897,26 @@ const Settings = () => {
<APITokenSettings />
<StorageSettings />
<div className='grid gap-4 py-4' id='sidepanel'>
<Typography level='h3'>Sidepanel Customization</Typography>
<Typography level='h3'>{t('sidepanel.title')}</Typography>
<Divider />
<Typography level='body-md'>
Customize the layout and visibility of cards in the sidepanel. the
section only available on large screen devices such as tablets and
desktops..
{t('sidepanel.detailedDescription')}
</Typography>
<SidepanelSettings />
</div>
<div className='grid gap-4 py-4' id='theme'>
<Typography level='h3'>Theme preferences</Typography>
<Typography level='h3'>{t('theme.title')}</Typography>
<Divider />
<Typography level='body-md'>
Choose how the site looks to you. Select a single theme, or sync with
your system and automatically switch between day and night themes.
</Typography>
<Typography level='body-md'>{t('theme.description')}</Typography>
<ThemeToggle />
</div>
<div className='grid gap-4 py-4' id='localization'>
<Typography level='h3'>Localization</Typography>
<Typography level='h3'>{t('localization.title')}</Typography>
<Divider />
<Typography level='body-md'>
Customize language, date format, and regional preferences for your
account. These settings will apply throughout the application.
{t('localization.descriptionLong')}
</Typography>
<LocalizationSettings />
</div>

View File

@@ -15,6 +15,7 @@ import * as chrono from 'chrono-node'
import moment from 'moment'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
@@ -186,6 +187,7 @@ const POINTS_SUGGESTIONS = {
const PARSE_DEBOUNCE_MS = 150
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { t } = useTranslation('chores')
const { ResponsiveModal } = useResponsiveModal()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
@@ -229,13 +231,13 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
value: 'userId',
display: 'displayName',
options: [
{ userId: 'anyone', displayName: 'Anyone' },
{ userId: 'anyone', displayName: t('addTask.anyone') },
...(circleMembers?.res || []),
],
},
'*': POINTS_SUGGESTIONS,
}),
[userLabels, circleMembers, handleCreateLabel],
[userLabels, circleMembers, handleCreateLabel, t],
)
const [taskText, setTaskText] = useState('')
@@ -1156,7 +1158,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
onClose={handleCloseModal}
size='lg'
fullWidth={true}
title='Create new task'
title={t('addTask.title')}
footer={
<ModalActions>
{!showScan && !showVoice && projects?.length >= 1 && (
@@ -1177,7 +1179,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
fontWeight: 'normal',
}}
>
{selectedProject.name}
{selectedProject.id === 'default'
? t('addTask.defaultProject')
: selectedProject.name}
</MenuButton>
<Menu
placement='top-start'
@@ -1199,7 +1203,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
sx={{ fontSize: 18, color: project.color }}
/>
</ListItemDecorator>
{project.name}
{project.id === 'default'
? t('addTask.defaultProject')
: project.name}
</MenuItem>
)
})}
@@ -1211,7 +1217,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
color='neutral'
onClick={handleCloseModal}
>
Cancel
{t('common:cancel')}
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='Esc'
@@ -1231,10 +1237,12 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
onClick={handleVoiceConfirm}
>
{creatingVoiceTasks
? 'Creating'
? t('addTask.creating')
: voiceState.segments.length > 1
? `Create ${voiceState.segments.length} Tasks`
: 'Use Task'}
? t('addTask.createCount', {
count: voiceState.segments.length,
})
: t('addTask.useTask')}
</Button>
)}
{showScan && scanState.primaryAction && (
@@ -1249,7 +1257,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
)}
{showScan && scanState.phase === 'processing' && (
<Button variant='solid' color='primary' loading disabled>
Processing
{t('addTask.processing')}
</Button>
)}
{!showScan && !showVoice && (
@@ -1260,7 +1268,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
disabled={!taskTitle.trim() || isAttachingScan}
onClick={submitChore}
>
Create
{t('addTask.create')}
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
@@ -1349,7 +1357,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
onVoiceClick={
voiceAvailable ? () => setShowVoice(true) : undefined
}
placeholder='Type your task...'
placeholder={t('addTask.taskPlaceholder')}
onChange={text => {
setTaskText(text)
if (!text) setTaskTitle('')
@@ -1484,7 +1492,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}}
>
<Add sx={{ fontSize: 20 }} />
<Typography level='body-sm'>Description</Typography>
<Typography level='body-sm'>
{t('addTask.descriptionChip')}
</Typography>
</Button>
)}
{!hasSubTasks && (
@@ -1507,7 +1517,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}}
>
<Add sx={{ fontSize: 20 }} />
<Typography level='body-sm'>Subtasks</Typography>
<Typography level='body-sm'>
{t('addTask.subtasksChip')}
</Typography>
</Button>
)}
<AdvancedOptionsTrigger