Merge remote-tracking branch 'origin/develop' into analytics

# Conflicts:
#	public/locales/en/settings.json
#	src/views/Settings/DeveloperSettings.jsx
#	src/views/Settings/SettingsOverview.jsx
This commit is contained in:
Mo Tarbin
2026-08-14 01:26:51 -04:00
76 changed files with 1379 additions and 764 deletions

View File

@@ -1,12 +1,14 @@
import { FormLabel, Input } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import ConfirmationModal from './Inputs/ConfirmationModal'
function EditHistoryModal({ config, historyRecord }) {
const { t } = useTranslation('history')
const { ResponsiveModal } = useResponsiveModal()
const [completedDate, setCompletedDate] = useState('')
@@ -40,12 +42,12 @@ function EditHistoryModal({ config, historyRecord }) {
onClose={config?.onClose}
size='lg'
// fullWidth={true}
title='Edit History'
title={t('edit.title')}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: config.onClose }}
secondary={{ label: t('common:cancel'), onClick: config.onClose }}
primary={{
label: 'Save',
label: t('common:save'),
onClick: () =>
config.onSave({
id: historyRecord.id,
@@ -57,7 +59,7 @@ function EditHistoryModal({ config, historyRecord }) {
/>
}
>
<FormLabel>Due Date</FormLabel>
<FormLabel>{t('chores:dueDate')}</FormLabel>
<Input
type='datetime-local'
value={dueDate}
@@ -66,7 +68,7 @@ function EditHistoryModal({ config, historyRecord }) {
}}
sx={{ mb: 2 }}
/>
<FormLabel>Completed Date</FormLabel>
<FormLabel>{t('edit.completedDate')}</FormLabel>
<Input
type='datetime-local'
value={completedDate}
@@ -75,12 +77,12 @@ function EditHistoryModal({ config, historyRecord }) {
}}
sx={{ mb: 2 }}
/>
<FormLabel>Note</FormLabel>
<FormLabel>{t('edit.note')}</FormLabel>
<Input
fullWidth
multiline
label='Additional Notes'
placeholder='Additional Notes'
label={t('edit.additionalNotes')}
placeholder={t('edit.additionalNotes')}
value={notes}
onChange={e => {
if (e.target.value.trim() === '') {
@@ -104,10 +106,10 @@ function EditHistoryModal({ config, historyRecord }) {
}
setIsDeleteModalOpen(false)
},
title: 'Delete History',
message: 'Are you sure you want to delete this history?',
confirmText: 'Delete',
cancelText: 'Cancel',
title: t('edit.deleteTitle'),
message: t('edit.deleteMessage'),
confirmText: t('common:delete'),
cancelText: t('common:cancel'),
color: 'danger',
}}
/>

View File

@@ -14,6 +14,7 @@ import {
} from '@mui/icons-material'
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
import moment from 'moment'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext'
@@ -22,13 +23,13 @@ import { TASK_COLOR } from '../../utils/Colors.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
const STATUS_CONFIG = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
0: { label: t('status.inProgress'), color: 'primary', icon: <AccessTime /> },
1: { label: t('status.completed'), color: 'success', icon: <Check /> },
2: { label: t('status.skipped'), color: 'warning', icon: <Redo /> },
3: { label: t('status.pendingApproval'), color: 'neutral', icon: <HourglassEmpty /> },
4: { label: t('status.rejected'), color: 'danger', icon: <ThumbDown /> },
5: { label: t('status.missed'), color: 'danger', icon: <RunningWithErrors /> },
6: { label: t('status.rescheduled'), color: 'warning', icon: <Schedule /> },
}
const DetailRow = ({ icon, label, value, children }) => (
@@ -55,6 +56,8 @@ const DetailRow = ({ icon, label, value, children }) => (
)
const TimingBadge = ({ historyEntry }) => {
const { t } = useTranslation('history')
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
if ([0, 5, 6].includes(historyEntry.status)) return null
@@ -71,7 +74,7 @@ const TimingBadge = ({ historyEntry }) => {
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
startDecorator={<Check />}
>
On Time
{t('badge.onTime')}
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
@@ -103,6 +106,7 @@ const TimingBadge = ({ historyEntry }) => {
}
function HistoryDetailModal({ config }) {
const { t } = useTranslation('history')
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
const navigate = useNavigate()
@@ -114,7 +118,9 @@ function HistoryDetailModal({ config }) {
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
const isFirstSchedule = entry.status === 6 && !entry.dueDate
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
const statusLabel = isFirstSchedule
? t('status.scheduled')
: t(statusCfg.labelKey)
const performer = performers.find(p => p.userId === entry.completedBy)
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
const isDifferentAssignee =
@@ -138,7 +144,7 @@ function HistoryDetailModal({ config }) {
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
title='Activity Detail'
title={t('detail.title')}
footer={
<ModalActions>
{entry.choreId && (
@@ -151,7 +157,7 @@ function HistoryDetailModal({ config }) {
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
{t('detail.openTask')}
</Button>
)}
{config?.onEdit && (
@@ -159,7 +165,7 @@ function HistoryDetailModal({ config }) {
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
{t('detail.editEntry')}
</Button>
)}
</ModalActions>
@@ -196,7 +202,7 @@ function HistoryDetailModal({ config }) {
{performer && (
<DetailRow
icon={<Check sx={{ fontSize: 16 }} />}
label='Performed by'
label={t('detail.performedBy')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar
@@ -216,7 +222,7 @@ function HistoryDetailModal({ config }) {
{isDifferentAssignee && assignedTo && (
<DetailRow
icon={<Person sx={{ fontSize: 16 }} />}
label='Assigned to'
label={t('detail.assignedTo')}
value={assignedTo.displayName}
/>
)}
@@ -259,7 +265,7 @@ function HistoryDetailModal({ config }) {
{showUpdatedAt && (
<DetailRow
icon={<Update sx={{ fontSize: 16 }} />}
label='Last updated'
label={t('detail.lastUpdated')}
value={fmt.dateTime(entry.updatedAt)}
/>
)}
@@ -268,7 +274,7 @@ function HistoryDetailModal({ config }) {
{entry.duration > 0 && (
<DetailRow
icon={<Schedule sx={{ fontSize: 16 }} />}
label='Duration'
label={t('detail.duration')}
value={formatDuration(entry.duration)}
/>
)}
@@ -277,8 +283,8 @@ function HistoryDetailModal({ config }) {
{entry.points > 0 && (
<DetailRow
icon={<Typography sx={{ fontSize: 14 }}></Typography>}
label='Points earned'
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
label={t('detail.pointsEarned')}
value={t('detail.points', { count: entry.points })}
/>
)}

View File

@@ -11,6 +11,7 @@ import FilterBuilderContent, {
import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine'
import { useFilters } from '../../Filters/FilterQueries'
import { useTranslation } from 'react-i18next'
const EMPTY_FILTERS = []
@@ -25,6 +26,7 @@ const AdvancedFilterBuilder = ({
userProfile = null,
editingFilter = null,
}) => {
const { t } = useTranslation('filters')
const [filterName, setFilterName] = useState('')
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [selections, setSelections] = useState(defaultSelections())
@@ -76,15 +78,15 @@ const AdvancedFilterBuilder = ({
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
setError(t('builder.errorName'))
return
}
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
setError('A filter with this name already exists')
setError(t('nameExists'))
return
}
if (conditions.length === 0) {
setError('Please configure at least one filter condition')
setError(t('builder.errorConditions'))
return
}
onSave({
@@ -106,7 +108,7 @@ const AdvancedFilterBuilder = ({
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{editingFilter ? 'Edit Filter' : 'New Filter'}
{editingFilter ? t('builder.editTitle') : t('builder.newTitle')}
{activeConditionCount > 0 && (
<Chip size='sm' variant='solid' color='primary'>
{activeConditionCount} condition
@@ -129,17 +131,17 @@ const AdvancedFilterBuilder = ({
{conditions.length > 0 ? (
<>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} task{previewCount !== 1 ? 's' : ''}
{t('tasks', { count: previewCount })}
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
{t('overdue', { count: previewOverdueCount })}
</Chip>
)}
</>
) : (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Add conditions to preview
{t('builder.addConditions')}
</Typography>
)}
</Box>
@@ -147,7 +149,7 @@ const AdvancedFilterBuilder = ({
{/* Actions */}
<ModalActions>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
{t('common:cancel')}
</Button>
<Button
variant='solid'
@@ -155,7 +157,7 @@ const AdvancedFilterBuilder = ({
startDecorator={<Save sx={{ fontSize: 16 }} />}
onClick={handleSave}
>
Save Filter
{t('builder.save')}
</Button>
</ModalActions>
</Box>
@@ -168,10 +170,10 @@ const AdvancedFilterBuilder = ({
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Filter Name
{t('builder.name')}
</Typography>
<Input
placeholder='e.g. Overdue tasks for Alice'
placeholder={t('builder.namePlaceholder')}
value={filterName}
onChange={e => {
setFilterName(e.target.value)
@@ -193,13 +195,13 @@ const AdvancedFilterBuilder = ({
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Color
{t('builder.color')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{FILTER_COLORS.map(c => (
<Box
key={c.value}
title={c.name}
title={t(`common:colors.${c.key}`)}
onClick={() => setFilterColor(c.value)}
sx={{
width: 26,

View File

@@ -1,4 +1,5 @@
import { Browser } from '@capacitor/browser'
import { useTranslation } from 'react-i18next'
import { Capacitor } from '@capacitor/core'
import { Download } from '@mui/icons-material'
import { Box, CircularProgress, Typography } from '@mui/joy'
@@ -29,6 +30,7 @@ const downloadUrl = (url, fileName) => {
}
function AttachmentViewerModal({ config }) {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal()
const [imgLoaded, setImgLoaded] = useState(false)
const [imgError, setImgError] = useState(false)
@@ -49,7 +51,7 @@ function AttachmentViewerModal({ config }) {
maxHeight='92vh'
footer={
<ModalActions
secondary={{ label: 'Close', onClick: handleClose }}
secondary={{ label: t('close'), onClick: handleClose }}
primary={{
label: 'Download',
startDecorator: <Download />,
@@ -73,7 +75,7 @@ function AttachmentViewerModal({ config }) {
)}
{imgError ? (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Failed to load image.
{t('imageLoadFailed')}
</Typography>
) : (
<Box

View File

@@ -11,12 +11,14 @@ import {
Tabs,
Typography,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const [activeTab, setActiveTab] = useState(0)
@@ -66,7 +68,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const handleCreateBackup = async () => {
if (!encryptionKey.trim()) {
setError('Encryption key is required')
setError(t('backup.keyRequired'))
return
}
@@ -95,7 +97,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
showNotification({
type: 'success',
message: 'Backup created and downloaded successfully',
message: t('backup.created'),
})
handleClose()
@@ -104,7 +106,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
setError(errorData.message || 'Failed to create backup')
}
} catch (err) {
setError('Failed to create backup')
setError(t('backup.createFailed'))
} finally {
setLoading(false)
}
@@ -120,12 +122,12 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const handleRestore = async () => {
if (!restoreEncryptionKey.trim()) {
setError('Encryption key is required')
setError(t('backup.keyRequired'))
return
}
if (!backupFile) {
setError('Please select a backup file')
setError(t('backup.selectFile'))
return
}
@@ -142,7 +144,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
if (response.ok) {
showNotification({
type: 'success',
message: 'Backup restored successfully. Please refresh the page.',
message: t('backup.restored'),
})
// Refresh the page after a short delay to allow user to see the message
@@ -156,20 +158,20 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
setError(errorData.message || 'Failed to restore backup')
}
} catch (err) {
setError('Failed to restore backup')
setError(t('backup.restoreFailed'))
} finally {
setLoading(false)
}
}
reader.onerror = () => {
setError('Failed to read backup file')
setError(t('backup.readFailed'))
setLoading(false)
}
reader.readAsText(backupFile)
} catch (err) {
setError('Failed to restore backup')
setError(t('backup.restoreFailed'))
setLoading(false)
}
}
@@ -198,8 +200,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const renderBackupTab = () => (
<Box>
<Typography level='body-md' mb={3}>
Create an encrypted backup of your data. This backup will include all
your chores, history, settings, and optionally your uploaded files.
{t('backup.createIntro')}
</Typography>
<FormControl sx={{ mb: 2 }}>
@@ -208,7 +209,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
type='password'
value={encryptionKey}
onChange={e => setEncryptionKey(e.target.value)}
placeholder='Enter a strong encryption key'
placeholder={t('backup.keyPlaceholder')}
/>
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Keep this key safeyou&apos;ll need it to restore your backup
@@ -228,7 +229,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
<Checkbox
checked={includeAssets}
onChange={e => setIncludeAssets(e.target.checked)}
label='Include uploaded files and assets'
label={t('backup.includeAssets')}
/>
</FormControl>
@@ -243,8 +244,8 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const renderRestoreTab = () => (
<Box>
<Typography level='body-md' mb={3} color='warning'>
<strong>Warning:</strong> Restoring a backup will replace all your
current data. This action cannot be undone.
<strong>{t('backup.warningLabel')}</strong>{' '}
{t('backup.restoreWarning')}
</Typography>
<FormControl sx={{ mb: 2 }}>
@@ -268,7 +269,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
type='password'
value={restoreEncryptionKey}
onChange={e => setRestoreEncryptionKey(e.target.value)}
placeholder='Enter the encryption key used for this backup'
placeholder={t('backup.restoreKeyPlaceholder')}
/>
</FormControl>
@@ -293,7 +294,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('accountSettings.cancel'),
onClick: handleClose,
disabled: loading,
}}

View File

@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const [childName, setChildName] = useState('')
@@ -19,39 +21,38 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
if (touched.childName) {
if (!childName.trim()) {
newErrors.childName = 'Sub account name is required'
newErrors.childName = t('childUsers.errNameRequired')
} else if (childName.length < 2) {
newErrors.childName = 'Sub account name must be at least 2 characters'
newErrors.childName = t('childUsers.errNameMin')
} else if (childName.length > 20) {
newErrors.childName = 'Sub account name must be less than 20 characters'
newErrors.childName = t('childUsers.errNameMax')
} else if (!/^[a-z.-]+$/.test(childName)) {
newErrors.childName =
'Sub account name can only contain lowercase letters, dot and dash'
newErrors.childName = t('childUsers.errNameChars')
}
}
if (touched.password) {
if (!password) {
newErrors.password = 'Password is required'
newErrors.password = t('childUsers.errPasswordRequired')
} else if (password.length < 8) {
newErrors.password = 'Password must be between 8 and 64 characters'
newErrors.password = t('childUsers.errPasswordLength')
} else if (password.length > 64) {
newErrors.password = 'Password must be between 8 and 64 characters'
newErrors.password = t('childUsers.errPasswordLength')
}
}
if (touched.confirmPassword) {
if (password !== confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match'
newErrors.confirmPassword = t('childUsers.errPasswordMatch')
}
}
if (touched.displayName && displayName.length > 50) {
newErrors.displayName = 'Display name must be less than 50 characters'
newErrors.displayName = t('childUsers.errDisplayNameMax')
}
setErrors(newErrors)
}, [childName, displayName, password, confirmPassword, touched])
}, [childName, displayName, password, confirmPassword, touched, t])
const handleSubmit = async () => {
setTouched({
@@ -101,7 +102,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<ResponsiveModal
open={isOpen}
onClose={handleClose}
title='Create Sub Account'
title={t('childUsers.createTitle')}
description='Create a login that can complete tasks assigned to this account.'
size='md'
closeOnBackdrop={!isSubmitting}
@@ -109,12 +110,12 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('accountSettings.cancel'),
onClick: handleClose,
disabled: isSubmitting,
}}
primary={{
label: 'Create Account',
label: t('childUsers.createButton'),
onClick: handleSubmit,
disabled: !isValid || isSubmitting,
loading: isSubmitting,
@@ -124,14 +125,14 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
>
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Sub Account Name *
{t('childUsers.nameLabel')}
</Typography>
<Input
required
fullWidth
id='childName'
name='childName'
placeholder='Enter sub account name (e.g., sarah)'
placeholder={t('childUsers.namePlaceholder')}
value={childName}
onChange={e => {
setChildName(e.target.value)
@@ -145,13 +146,13 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<FormControl error={!!errors.displayName} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Display Name
{t('childUsers.displayNameLabel')}
</Typography>
<Input
fullWidth
id='displayName'
name='displayName'
placeholder='Display name (optional, defaults to sub account name)'
placeholder={t('childUsers.displayNamePlaceholder')}
value={displayName}
onChange={e => {
setDisplayName(e.target.value)
@@ -165,7 +166,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<FormControl error={!!errors.password} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Password *
{t('childUsers.passwordLabel')}
</Typography>
<Input
required
@@ -173,7 +174,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
name='password'
type='password'
id='password'
placeholder='Enter password (8-64 characters)'
placeholder={t('childUsers.passwordPlaceholder')}
value={password}
onChange={e => {
setPassword(e.target.value)
@@ -185,7 +186,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<FormControl error={!!errors.confirmPassword} sx={{ mb: 3 }}>
<Typography level='body2' mb={1}>
Confirm Password *
{t('childUsers.confirmPasswordLabel')}
</Typography>
<Input
required
@@ -193,7 +194,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
name='confirmPassword'
type='password'
id='confirmPassword'
placeholder='Confirm password'
placeholder={t('childUsers.confirmPasswordPlaceholder')}
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value)

View File

@@ -10,8 +10,10 @@ import {
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
const { t } = useTranslation('things')
const { ResponsiveModal } = useResponsiveModal()
const [name, setName] = useState(currentThing?.name || '')
@@ -33,17 +35,17 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
const isValid = () => {
const newErrors = {}
if (!name || name.trim() === '') {
newErrors.name = 'Name is required'
newErrors.name = t('errName')
}
if (type === 'number' && isNaN(state)) {
newErrors.state = 'State must be a number'
newErrors.state = t('errStateNumber')
}
if (type === 'boolean' && !['true', 'false'].includes(state)) {
newErrors.state = 'State must be true or false'
newErrors.state = t('errStateBool')
}
if ((type === 'text' && !state) || state.trim() === '') {
newErrors.state = 'State is required'
newErrors.state = t('errStateRequired')
}
setErrors(newErrors)
@@ -67,7 +69,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('common:cancel'),
onClick: onClose,
}}
primary={{
@@ -78,9 +80,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
}
>
<FormControl>
<Typography>Name</Typography>
<Typography>{t('name')}</Typography>
<Textarea
placeholder='Thing name'
placeholder={t('namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
sx={{ minWidth: 300 }}
@@ -101,9 +103,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
</FormControl>
{type === 'text' && (
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
value={state || ''}
onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }}
@@ -113,9 +115,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
)}
{type === 'number' && (
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
type='number'
value={state || ''}
onChange={e => {

View File

@@ -2,8 +2,10 @@ import { Input } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function DateModal({ isOpen, onClose, onSave, current, title }) {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal()
const [date, setDate] = useState(
current ? new Date(current).toISOString().split('T')[0] : '',
@@ -22,8 +24,8 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
title={title}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
secondary={{ label: t('cancel'), onClick: onClose }}
primary={{ label: t('save'), onClick: handleSave, disabled: !date }}
/>
}
>

View File

@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
const { t } = useTranslation('things')
const { ResponsiveModal } = useResponsiveModal()
const [state, setState] = useState(currentThing?.state || '')
@@ -13,7 +15,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
const newErrors = {}
if (state.trim() === '') {
newErrors.state = 'State is required'
newErrors.state = t('errStateRequired')
}
setErrors(newErrors)
@@ -38,18 +40,18 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
open={isOpen}
onClose={onClose}
size='sm'
title='Update state'
title={t('updateState')}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Update', onClick: handleSave }}
secondary={{ label: t('common:cancel'), onClick: onClose }}
primary={{ label: t('update'), onClick: handleSave }}
/>
}
>
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
value={state || ''}
onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }}

View File

@@ -3,6 +3,7 @@ import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
import PROJECT_ICONS from '../../../utils/ProjectIcons'
import { useTranslation } from 'react-i18next'
const IconPickerModal = ({
isOpen,
@@ -11,6 +12,7 @@ const IconPickerModal = ({
currentIcon,
projectColor,
}) => {
const { t } = useTranslation('projects')
const { ResponsiveModal } = useResponsiveModal()
const handleIconClick = iconValue => {
@@ -25,13 +27,13 @@ const IconPickerModal = ({
size='lg'
fullWidth={true}
unmountDelay={250}
title='Choose Project Icon'
title={t('iconPicker.chooseIcon')}
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
<ModalActions secondary={{ label: t('common:cancel'), onClick: onClose }} />
}
>
<FormControl>
<FormLabel>Available Icons</FormLabel>
<FormLabel>{t('iconPicker.availableIcons')}</FormLabel>
<Grid
container
spacing={1}
@@ -85,7 +87,7 @@ const IconPickerModal = ({
lineHeight: 1.2,
}}
>
{iconData.name}
{t(`icons.${iconData.key}`)}
</Typography>
</Box>
</Grid>

View File

@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function PasswordChangeModal({ isOpen, onClose }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
@@ -17,13 +19,13 @@ function PasswordChangeModal({ isOpen, onClose }) {
if (password !== confirmPassword) {
setPasswordError('Passwords do not match')
} else if (password.length < 8) {
setPasswordError('Password must be at least 8 characters')
setPasswordError(t('passwordChange.minLength'))
} else if (password.length > 64) {
setPasswordError('Password must be less than 64 characters')
setPasswordError(t('passwordChange.maxLength'))
} else {
setPasswordError(null)
}
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched])
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched, t])
const handleAction = isConfirmed => onClose(isConfirmed ? password : null)
const canSubmit =
@@ -38,13 +40,13 @@ function PasswordChangeModal({ isOpen, onClose }) {
open={isOpen}
onClose={() => handleAction(false)}
size='sm'
title='Change Password'
title={t('accountSettings.changePassword')}
description='Choose a password between 8 and 64 characters.'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: () => handleAction(false) }}
secondary={{ label: t('accountSettings.cancel'), onClick: () => handleAction(false) }}
primary={{
label: 'Change Password',
label: t('accountSettings.changePassword'),
disabled: !canSubmit,
onClick: () => handleAction(true),
}}

View File

@@ -2,6 +2,7 @@ import { Textarea } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function TextModal({
isOpen,
@@ -12,6 +13,7 @@ function TextModal({
okText,
cancelText,
}) {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal()
const [text, setText] = useState(current)
@@ -35,7 +37,7 @@ function TextModal({
>
<Textarea
autoFocus
placeholder='Type in here…'
placeholder={t('typeHere')}
value={text}
onChange={event => setText(event.target.value)}
minRows={3}

View File

@@ -12,6 +12,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocalization } from '../../../contexts/LocalizationContext'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
@@ -24,6 +25,7 @@ import {
import ConfirmationModal from './ConfirmationModal'
const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const { t } = useTranslation('timer')
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
@@ -181,8 +183,8 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{
onSuccess: () => {
showSuccess({
title: 'Session updated',
message: 'Timer session has been updated successfully.',
title: t('toast.sessionUpdatedTitle'),
message: t('toast.sessionUpdatedMessage'),
})
refetchTimer()
cancelEditingSession(sessionId)
@@ -190,15 +192,15 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
},
onError: () => {
showError({
title: 'Failed to update session',
message: 'Please try again.',
title: t('toast.sessionUpdateFailTitle'),
message: t('toast.tryAgain'),
})
},
},
)
} catch (error) {
showError({
title: 'Error updating session',
title: t('toast.sessionUpdateErrorTitle'),
message: error.message,
})
} finally {
@@ -213,15 +215,15 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{
onSuccess: () => {
showSuccess({
title: 'Session deleted',
message: 'Timer session has been deleted successfully.',
title: t('toast.sessionDeletedTitle'),
message: t('toast.sessionDeletedMessage'),
})
refetchTimer()
onTimerUpdate?.()
},
onError: error => {
showError({
title: 'Error deleting session',
title: t('toast.sessionDeleteErrorTitle'),
message: error.message,
})
},
@@ -235,10 +237,10 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const confirmDeleteSession = sessionId => {
setConfirmDeleteConfig({
isOpen: true,
title: 'Delete Timer Session',
message: 'Are you sure you want to delete this timer session?',
confirmText: 'Delete',
cancelText: 'Cancel',
title: t('toast.deleteConfirmTitle'),
message: t('toast.deleteConfirmMessage'),
confirmText: t('common:delete'),
cancelText: t('common:cancel'),
color: 'danger',
onClose: isConfirmed => {
if (isConfirmed) {
@@ -310,23 +312,23 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
tertiary={
!loading && timerData && !editingSessions[timerData.id]
? {
label: 'Delete',
label: t('common:delete'),
color: 'danger',
onClick: () => confirmDeleteSession(timerData.id),
}
: undefined
}
secondary={{ label: 'Close', onClick: handleClose }}
secondary={{ label: t('common:close'), onClick: handleClose }}
primary={
!loading && timerData
? editingSessions[timerData.id]
? {
label: 'Save',
label: t('common:save'),
onClick: () => saveSession(timerData.id),
loading,
}
: {
label: 'Edit',
label: t('common:edit'),
startDecorator: <Edit />,
onClick: () => startEditingSession(),
}
@@ -337,13 +339,13 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
>
{loading && (
<Alert color='neutral' sx={{ mb: 2 }}>
Loading timer data...
{t('loading')}
</Alert>
)}
{!loading && !timerData && (
<Alert color='warning' sx={{ mb: 2 }}>
No timer data found for this chore.
{t('noData')}
</Alert>
)}
@@ -403,7 +405,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Active Work
{t('activeWork')}
</Typography>
</Box>
<Box>
@@ -457,7 +459,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Break Time
{t('breakTime')}
</Typography>
</Box>
<Box>
@@ -511,7 +513,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Work Sessions
{t('workSessions')}
</Typography>
</Box>
<Box>
@@ -565,7 +567,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Total Time
{t('totalTime')}
</Typography>
</Box>
<Box>
@@ -597,12 +599,12 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
>
Work vs Break Distribution
{t('distribution')}
</Typography>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{calculateCurrentActiveDuration() > 0
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
: 'No active time yet'}
: t('noActiveTime')}
</Typography>
</Box>
<Box
@@ -630,7 +632,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{/* Time Session */}
<Box>
<Typography level='h4' sx={{ mb: 2 }}>
Session Breakdown
{t('sessionBreakdown')}
</Typography>
<Box>
@@ -644,7 +646,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-sm'
sx={{ fontWeight: 'bold', mb: 2 }}
>
Work Sessions ({timerData.pauseLog.length})
{t('workSessions')} ({timerData.pauseLog.length})
</Typography>
<Box
@@ -795,7 +797,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-sm'
sx={{ fontWeight: 'bold' }}
>
Sessions
{t('sessions')}
</Typography>
<Button
size='sm'
@@ -803,7 +805,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
startDecorator={<Add />}
onClick={() => addPauseLogEntry(timerData.id)}
>
Add Session
{t('addSession')}
</Button>
</Box>
@@ -855,7 +857,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ fontWeight: 'bold' }}
>
Start Time
{t('startTime')}
</Typography>
<Input
type='datetime-local'
@@ -878,7 +880,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ fontWeight: 'bold' }}
>
End Time
{t('endTime')}
</Typography>
<Input
type='datetime-local'
@@ -903,7 +905,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
}
/>
<FormHelperText>
Leave empty if session is ongoing
{t('leaveEmpty')}
</FormHelperText>
</FormControl>
@@ -941,7 +943,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{!timerData && (
<Alert color='neutral' sx={{ mt: 2 }}>
No timer session found for this chore.
{t('noSessionForChore')}
</Alert>
)}
</Box>

View File

@@ -14,8 +14,10 @@ import ModalActions from '../../../components/common/ModalActions'
import { useNavigate } from 'react-router-dom'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
import { useTranslation } from 'react-i18next'
function UserDeletionModal({ isOpen, onClose }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const Navigate = useNavigate()
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
@@ -47,7 +49,7 @@ function UserDeletionModal({ isOpen, onClose }) {
const checkDeletionRequirements = async () => {
if (password.trim() === '') {
setError('Please enter your password to continue')
setError(t('deletion.passwordRequired'))
return
}
@@ -97,7 +99,7 @@ function UserDeletionModal({ isOpen, onClose }) {
const executeUserDeletion = async () => {
if (password.trim() === '' || confirmation !== 'DELETE') {
setError('Please enter your password and type DELETE to confirm')
setError(t('deletion.passwordAndDelete'))
return
}
@@ -120,7 +122,7 @@ function UserDeletionModal({ isOpen, onClose }) {
}
} catch (err) {
console.error('Failed to delete account:', err)
setError('Failed to delete account')
setError(t('deletion.failed'))
} finally {
setLoading(false)
}
@@ -181,7 +183,7 @@ function UserDeletionModal({ isOpen, onClose }) {
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='Enter your password'
placeholder={t('deletion.passwordPlaceholder')}
/>
</FormControl>
@@ -196,7 +198,7 @@ function UserDeletionModal({ isOpen, onClose }) {
const renderTransferStep = () => (
<>
<Typography level='body-md' mb={3}>
You own circles that require ownership transfer before deletion. Please
{t('deletion.transferIntro')}
select new owners:
</Typography>
@@ -208,7 +210,7 @@ function UserDeletionModal({ isOpen, onClose }) {
<FormControl>
<FormLabel>New Owner</FormLabel>
<Select
placeholder='Select new owner'
placeholder={t('deletion.selectOwner')}
value={
transferOptions.find(t => t.circleId === circle.id)
?.newOwnerId || ''
@@ -237,8 +239,7 @@ function UserDeletionModal({ isOpen, onClose }) {
const renderConfirmationStep = () => (
<>
<Typography level='body-md' mb={3}>
Please enter your password and type <strong>DELETE</strong> to confirm
account deletion.
{t('deletion.confirmPrompt')}
</Typography>
<Typography level='body-sm' mb={2}>
on successful deletion, you will be logged out and redirected to the
@@ -251,7 +252,7 @@ function UserDeletionModal({ isOpen, onClose }) {
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='Enter your password'
placeholder={t('deletion.passwordPlaceholder')}
/>
</FormControl>
@@ -260,7 +261,7 @@ function UserDeletionModal({ isOpen, onClose }) {
<Input
value={confirmation}
onChange={e => setConfirmation(e.target.value)}
placeholder='DELETE'
placeholder={t('deletion.typeDelete')}
/>
</FormControl>
@@ -304,7 +305,7 @@ function UserDeletionModal({ isOpen, onClose }) {
<ModalActions
stackOnMobile
secondary={{
label: 'Cancel',
label: t('accountSettings.cancel'),
onClick: () => handleClose(false),
}}
primary={{

View File

@@ -1,8 +1,10 @@
import { Avatar, Box, List, ListItem, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal()
return (
@@ -13,7 +15,7 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
fullWidth={true}
title='Select User'
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
<ModalActions secondary={{ label: t('cancel'), onClick: onClose }} />
}
>
<List sx={{ mb: 2 }}>

View File

@@ -11,6 +11,7 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import ModalActions from '../../components/common/ModalActions.jsx'
@@ -18,6 +19,7 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
function RedeemPointsModal({ config }) {
const { t } = useTranslation('points')
const { ResponsiveModal } = useResponsiveModal()
const [points, setPoints] = useState(0)
@@ -56,12 +58,12 @@ function RedeemPointsModal({ config }) {
open={config?.isOpen}
onClose={config?.onClose}
size='md'
title='Redeem Points'
title={t('redeem')}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: config?.onClose }}
secondary={{ label: t('common:cancel'), onClick: config?.onClose }}
primary={{
label: 'Redeem',
label: t('redeemModal.redeemButton'),
startDecorator: <CreditCard />,
disabled: !canRedeem,
onClick: () =>
@@ -103,7 +105,7 @@ function RedeemPointsModal({ config }) {
startDecorator={<Toll />}
sx={{ mt: 0.5 }}
>
{config?.available || 0} points available
{t('redeemModal.pointsAvailable', { count: config?.available || 0 })}
</Chip>
</Box>
</Stack>
@@ -112,7 +114,7 @@ function RedeemPointsModal({ config }) {
{/* Points Input Section */}
<FormControl>
<FormLabel sx={{ fontWeight: 600, mb: 1 }}>
Points to Redeem
{t('redeemModal.pointsToRedeem')}
</FormLabel>
<Input
type='number'
@@ -124,7 +126,7 @@ function RedeemPointsModal({ config }) {
input: {
min: 0,
max: config?.available || 0,
placeholder: 'Enter points...',
placeholder: t('redeemModal.placeholder'),
},
}}
onChange={e => handlePointsChange(e.target.value)}
@@ -140,7 +142,7 @@ function RedeemPointsModal({ config }) {
/>
{points > config?.available && (
<Typography level='body-xs' sx={{ color: 'danger.500', mt: 0.5 }}>
Cannot exceed available points
{t('redeemModal.cannotExceed')}
</Typography>
)}
</FormControl>
@@ -148,7 +150,7 @@ function RedeemPointsModal({ config }) {
{/* Quick Selection Buttons */}
<Box>
<Typography level='body-sm' sx={{ fontWeight: 600, mb: 1.5 }}>
Quick Add:
{t('redeemModal.quickAdd')}
</Typography>
<Stack
direction='row'
@@ -199,19 +201,21 @@ function RedeemPointsModal({ config }) {
}}
>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
You are about to redeem
{t('redeemModal.aboutToRedeem')}
</Typography>
<Typography
level='h4'
sx={{ color: 'primary.600', fontWeight: 700 }}
>
{points} points
{t('redeemModal.amount', { count: points })}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'text.secondary', mt: 0.5 }}
>
Remaining: {(config?.available || 0) - points} points
{t('redeemModal.remaining', {
count: (config?.available || 0) - points,
})}
</Typography>
</Card>
)}