Files
donetick/src/views/Settings/Settings.jsx
Mo Tarbin c1318a2cc4 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
2026-08-18 01:11:26 -04:00

975 lines
34 KiB
JavaScript

import { Capacitor } from '@capacitor/core'
import { Delete, Refresh } from '@mui/icons-material'
import {
Box,
Button,
Card,
Checkbox,
Chip,
CircularProgress,
Container,
Divider,
FormControl,
FormHelperText,
Input,
Option,
Select,
Typography,
} from '@mui/joy'
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'
import SubscriptionModal from '../../components/SubscriptionModal'
import { useLocalization } from '../../contexts/LocalizationContext'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
AcceptCircleMemberRequest,
CancelSubscription,
DeleteCircleMember,
GetAllCircleMembers,
GetCircleMemberRequests,
GetUserCircle,
JoinCircle,
LeaveCircle,
PutWebhookURL,
UpdateMemberRole,
UpdatePassword,
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NativeCancelSubscriptionModal from '../Modals/Inputs/NativeCancelSubscriptionModal'
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
import APITokenSettings from './APITokenSettings'
import LocalizationSettings from './LocalizationSettings'
import MFASettings from './MFASettings'
import NotificationSetting from './NotificationSetting'
import ProfileSettings from './ProfileSettings'
import SidepanelSettings from './SidepanelSettings'
import StorageSettings from './StorageSettings'
import ThemeToggle from './ThemeToggle'
const Settings = () => {
const { t } = useTranslation('settings')
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const navigate = useNavigate()
const { fmt } = useLocalization()
const [userCircles, setUserCircles] = useState([])
const [circleMemberRequests, setCircleMemberRequests] = useState([])
const [circleInviteCode, setCircleInviteCode] = useState('')
const [circleMembers, setCircleMembers] = useState([])
const [webhookURL, setWebhookURL] = useState(null)
const [webhookError, setWebhookError] = useState(null)
const [isAdmin, setIsAdmin] = useState(false)
const [lastRefresh, setLastRefresh] = useState(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const [changePasswordModal, setChangePasswordModal] = useState(false)
const [subscriptionModal, setSubscriptionModal] = useState(false)
const [userDeletionModal, setUserDeletionModal] = useState(false)
const [nativeCancelModal, setNativeCancelModal] = useState(false)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
message,
title,
onConfirm,
confirmText = t('common.confirm'),
cancelText = t('common.cancel'),
color = 'primary',
) => {
setConfirmModalConfig({
isOpen: true,
message,
title,
confirmText,
cancelText,
color,
onClose: isConfirmed => {
if (isConfirmed) {
onConfirm()
}
setConfirmModalConfig({})
},
})
}
const refreshMemberRequests = async () => {
setIsRefreshing(true)
try {
const resp = await GetCircleMemberRequests()
const data = await resp.json()
setCircleMemberRequests(data.res ? data.res : [])
setLastRefresh(new Date())
} catch (error) {
showNotification({
type: 'error',
message: t('circleSettings.refreshFailed'),
})
} finally {
setIsRefreshing(false)
}
}
useEffect(() => {
GetUserCircle().then(resp => {
resp.json().then(data => {
setUserCircles(data.res ? data.res : [])
setWebhookURL(data.res ? data.res[0].webhook_url : null)
})
})
GetCircleMemberRequests().then(resp => {
resp.json().then(data => {
setCircleMemberRequests(data.res ? data.res : [])
setLastRefresh(new Date())
})
})
GetAllCircleMembers().then(data => {
setCircleMembers(data.res ? data.res : [])
})
}, [])
useEffect(() => {
async function configurePurchases() {
if (Capacitor.isNativePlatform() && userProfile) {
await Purchases.configure({
apiKey:
Capacitor.getPlatform() === 'ios'
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
appUserID: String(userProfile?.id),
})
}
}
configurePurchases()
}, [userProfile])
// useEffect when circleMembers and userprofile:
useEffect(() => {
if (userProfile && userProfile.id) {
const isUserAdmin = circleMembers.some(
member => member.userId === userProfile.id && member.role === 'admin',
)
setIsAdmin(isUserAdmin)
}
}, [circleMembers, userProfile])
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash
if (hash) {
// Small delay to ensure the component is fully rendered before scrolling
setTimeout(() => {
const section = document.getElementById(hash.slice(1))
if (section) {
// Get the element position and scroll with some offset for the title
const elementPosition = section.offsetTop
const offsetPosition = elementPosition - 20 // 20px padding above the title
window.scrollTo({
top: offsetPosition,
behavior: 'instant', // Use 'smooth' for smooth scrolling
})
}
}, 500)
}
}
// Handle initial hash on mount
handleHashChange()
// Listen for hash changes
window.addEventListener('hashchange', handleHashChange)
return () => {
window.removeEventListener('hashchange', handleHashChange)
}
}, [])
const getSubscriptionDetails = () => {
if (userProfile?.subscription === 'active') {
return t('accountSettings.activeDescription', {
date: fmt.date(userProfile?.expiration),
})
} else if (userProfile?.subscription === 'cancelled') {
return t('accountSettings.cancelledDescription', {
date: fmt.date(userProfile?.expiration),
})
} else {
return t('accountSettings.freeDescription')
}
}
const getSubscriptionStatus = () => {
if (userProfile?.subscription === 'active') {
return t('accountSettings.plus')
} else if (userProfile?.subscription === 'cancelled') {
if (moment().isBefore(userProfile?.expiration)) {
return t('accountSettings.plusUntil', {
date: fmt.date(userProfile?.expiration),
})
}
return t('accountSettings.free')
} else {
return t('accountSettings.free')
}
}
if (userProfile === null) {
return (
<Container className='flex h-full items-center justify-center'>
<Box className='flex flex-col items-center justify-center'>
<CircularProgress
color='success'
sx={{ '--CircularProgress-size': '200px' }}
>
<Logo />
</CircularProgress>
</Box>
</Container>
)
}
if (!userProfile) {
return <LoadingComponent />
}
return (
<Container>
<ProfileSettings />
<div className='grid gap-4 py-4' id='circle'>
<Typography level='h3'>{t('circleSettings.title')}</Typography>
<Divider />
<Typography level='body-md'>
{t('circleSettings.description')}
</Typography>
<Typography level='title-sm' mb={-1}>
{userCircles[0]?.userRole === 'member'
? t('circleSettings.memberOf', { name: userCircles[0]?.name })
: t('circleSettings.yourCircleCode')}
<Input
value={userCircles[0]?.invite_code}
disabled
size='lg'
sx={{
width: '220px',
mb: 1,
}}
/>
<Button
variant='soft'
onClick={() => {
navigator.clipboard.writeText(userCircles[0]?.invite_code)
showNotification({
type: 'success',
message: t('circleSettings.codeCopied'),
})
}}
>
{t('circleSettings.copyCode')}
</Button>
<Button
variant='soft'
sx={{ ml: 1 }}
onClick={() => {
navigator.clipboard.writeText(
window.location.protocol +
'//' +
window.location.host +
`/circle/join?code=${userCircles[0]?.invite_code}`,
)
showNotification({
type: 'success',
message: t('circleSettings.linkCopied'),
})
}}
>
{t('circleSettings.copyLink')}
</Button>
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
<Button
color='danger'
variant='outlined'
sx={{ ml: 1 }}
onClick={() => {
showConfirmation(
t('circleSettings.leaveConfirmMessage'),
t('circleSettings.leaveConfirmTitle'),
() => {
LeaveCircle(userCircles[0]?.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: t('circleSettings.leftCircle'),
})
} else {
showNotification({
type: 'error',
message: t('circleSettings.leaveFailed'),
})
}
})
},
t('circleSettings.leaveConfirmButton'),
t('common.cancel'),
'danger',
)
}}
>
{t('circleSettings.leave')}
</Button>
)}
</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' }}>
<Box>
<Typography level='body-md'>
{member.displayName.charAt(0).toUpperCase() +
member.displayName.slice(1)}
{member.userId === userProfile.id
? t('circleSettings.you')
: ''}{' '}
<Chip>
{' '}
{member.isActive
? member.role
: t('circleSettings.pendingApproval')}
</Chip>
</Typography>
{member.isActive ? (
<Typography level='body-sm'>
{t('circleSettings.joinedOn', {
date: fmt.date(member.createdAt),
})}
</Typography>
) : (
<Typography level='body-sm' color='danger'>
{t('circleSettings.requestedToJoin', {
date: fmt.date(member.updatedAt),
})}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{member.userId !== userProfile.id && isAdmin && (
<Select
size='sm'
sx={{ mr: 1 }}
value={member.role}
renderValue={() => (
<Typography>
{member.role.charAt(0).toUpperCase() +
member.role.slice(1)}
</Typography>
)}
onChange={(e, value) => {
UpdateMemberRole(member.userId, value).then(resp => {
if (resp.ok) {
const newCircleMembers = circleMembers.map(m => {
if (m.userId === member.userId) {
m.role = value
}
return m
})
setCircleMembers(newCircleMembers)
} else {
showNotification({
type: 'error',
message: t('circleSettings.roleUpdateFailed'),
})
}
})
}}
>
{['member', 'manager', 'admin'].map((option, index) => (
<Option value={option} key={index}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'start',
alignItems: 'start',
width: '100%',
gap: 0.5,
}}
>
<Typography
level='title-sm'
sx={{ mb: 0, mt: 0, lineHeight: 1.1 }}
>
{t(`circleSettings.roles.${option}`)}
</Typography>
<Typography
level='body-sm'
sx={{ mt: 0, mb: 0, lineHeight: 1.1 }}
>
{t(`circleSettings.roles.${option}Description`)}
</Typography>
</Box>
</Option>
))}
</Select>
)}
{isAdmin &&
member.userId !== userProfile.id &&
member.isActive && (
<Button
variant='outlined'
color='danger'
size='sm'
onClick={() => {
showConfirmation(
t('circleSettings.removeMemberMessage', {
name: member.displayName,
}),
t('circleSettings.removeMemberTitle'),
() => {
DeleteCircleMember(
member.circleId,
member.userId,
).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: t('circleSettings.memberRemoved'),
})
// Invalidate and refetch circle-related queries
queryClient.invalidateQueries(['circleMembers'])
queryClient.invalidateQueries(['userCircle'])
queryClient.refetchQueries(['circleMembers'])
queryClient.refetchQueries(['userCircle'])
// Update local state immediately
setCircleMembers(prevMembers =>
prevMembers.filter(
m => m.userId !== member.userId,
),
)
}
})
},
t('common.remove'),
t('common.cancel'),
'danger',
)
}}
>
<Delete />
</Button>
)}
</Box>
</Box>
</Card>
))}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='title-md'>
{t('circleSettings.circleMemberRequests')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{lastRefresh && (
<Typography level='body-sm' color='neutral'>
{t('circleSettings.lastUpdated', {
time: fmt.dateTime(lastRefresh),
})}
</Typography>
)}
<Button
size='sm'
variant='soft'
onClick={refreshMemberRequests}
disabled={isRefreshing}
startDecorator={
isRefreshing ? <CircularProgress size='sm' /> : <Refresh />
}
>
{isRefreshing
? t('circleSettings.refreshing')
: t('common.refresh')}
</Button>
</Box>
</Box>
{circleMemberRequests.map(request => (
<Card key={request.id} className='p-4'>
<Typography level='body-md'>
{t('circleSettings.wantsToJoin', { name: request.displayName })}
</Typography>
<Button
variant='soft'
color='success'
onClick={() => {
showConfirmation(
t('circleSettings.acceptRequestMessage', {
name: request.displayName,
username: request.username,
}),
t('circleSettings.acceptRequestTitle'),
() => {
AcceptCircleMemberRequest(request.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: t('circleSettings.requestAccepted'),
})
// Invalidate and refetch circle-related queries
queryClient.invalidateQueries(['circleMembers'])
queryClient.invalidateQueries(['circleMemberRequests'])
queryClient.invalidateQueries(['userCircle'])
queryClient.refetchQueries(['circleMembers'])
queryClient.refetchQueries(['circleMemberRequests'])
queryClient.refetchQueries(['userCircle'])
// Refresh local state
refreshMemberRequests()
GetAllCircleMembers().then(data => {
setCircleMembers(data.res ? data.res : [])
})
}
})
},
t('circleSettings.accept'),
t('common.cancel'),
)
}}
>
{t('circleSettings.accept')}
</Button>
</Card>
))}
<Divider> {t('circleSettings.or')} </Divider>
<Typography level='body-md'>
{t('circleSettings.joinOtherDescription')}
</Typography>
<Typography level='title-sm' mb={-1}>
{t('circleSettings.enterCircleCode')}
<Input
placeholder={t('circleSettings.enterCodePlaceholder')}
value={circleInviteCode}
onChange={e => setCircleInviteCode(e.target.value)}
size='lg'
sx={{
width: '220px',
mb: 1,
}}
/>
<Button
variant='soft'
onClick={() => {
JoinCircle(circleInviteCode).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: t('circleSettings.joinedPending'),
})
setTimeout(() => navigate('/'), 3000)
} else {
if (resp.status === 409) {
showNotification({
type: 'error',
message: t('circleSettings.alreadyMember'),
})
} else {
showNotification({
type: 'error',
message: t('circleSettings.joinFailed'),
})
}
setTimeout(() => navigate('/'), 3000)
}
})
}}
>
{t('circleSettings.joinCircle')}
</Button>
</Typography>
{circleMembers.find(m => userProfile.id == m.userId)?.role ===
'admin' && (
<>
<Typography level='title-lg' mt={2}>
{t('advanced.webhookTitle')}
</Typography>
<Typography level='body-md' mt={-1}>
{t('advanced.webhookDescription')}
</Typography>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
{t('advanced.webhookPlusNotice')}
</Typography>
)}
<FormControl sx={{ mt: 1 }}>
<Checkbox
checked={webhookURL !== null}
onClick={() => {
if (webhookURL === null) {
setWebhookURL('')
} else {
setWebhookURL(null)
}
}}
variant='soft'
label={t('advanced.webhookToggle')}
disabled={!isPlusAccount(userProfile)}
overlay
/>
<FormHelperText
sx={{
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
}}
>
{t('advanced.webhookHelper')}{' '}
{userProfile && !isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning'>
{t('common.plusFeature')}
</Chip>
)}
</FormHelperText>
</FormControl>
{webhookURL !== null && (
<Box>
<Typography level='title-sm'>
{t('advanced.webhookURL')}
</Typography>
<Input
value={webhookURL ? webhookURL : ''}
onChange={e => setWebhookURL(e.target.value)}
size='lg'
sx={{
width: '220px',
mb: 1,
}}
/>
{webhookError && (
<Typography level='body-sm' color='danger'>
{webhookError}
</Typography>
)}
<Button
variant='soft'
sx={{ width: '110px', mt: 1 }}
onClick={() => {
PutWebhookURL(webhookURL).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: t('advanced.webhookUpdated'),
})
} else {
showNotification({
type: 'error',
message: t('advanced.webhookUpdateFailed'),
})
}
})
}}
disabled={!isPlusAccount(userProfile)}
>
{t('common.save')}
</Button>
</Box>
)}
</>
)}
{/* WebSocket Settings */}
{/* <WebSocketSettings /> */}
<RealTimeSettings />
</div>
<div className='grid gap-4 py-4' id='account'>
<Typography level='h3'>{t('accountSettings.title')}</Typography>
<Divider />
<Typography level='body-md'>
{t('accountSettings.description')}
</Typography>
<Typography level='title-md' mb={-1}>
{t('accountSettings.accountType', { type: getSubscriptionStatus() })}
</Typography>
<Typography level='body-sm'>{getSubscriptionDetails()}</Typography>
<Box>
<Button
sx={{
width: '110px',
mb: 1,
}}
disabled={
userProfile?.subscription === 'active' ||
(moment(userProfile?.expiration).isAfter(moment()) &&
userProfile?.subscription !== 'cancelled')
}
onClick={async () => {
if (Capacitor.isNativePlatform()) {
try {
const { RevenueCatUI } =
await import('@revenuecat/purchases-capacitor-ui')
const offering = await Purchases.getOfferings()
await RevenueCatUI.presentPaywall({
offering: offering.current,
})
// Check if user now has entitlement after paywall interaction
const { customerInfo } = await Purchases.getCustomerInfo()
if (customerInfo.entitlements.active['Donetick Plus']) {
queryClient.invalidateQueries(['userProfile'])
queryClient.refetchQueries(['userProfile'])
showNotification({
type: 'success',
message: t('accountSettings.purchase.success'),
})
// invalidate user profile to get new subscription status:
}
} catch (error) {
console.log('Purchase error:', error)
// Handle different error types
if (error.code === '1') {
// User cancelled - don't show error
return
} else if (error.code === '2') {
// Store problem
showNotification({
type: 'error',
message: t('accountSettings.purchase.storeConnection'),
})
} else if (error.code === '3') {
// Purchase not allowed
showNotification({
type: 'error',
message: t('accountSettings.purchase.notAllowed'),
})
} else if (error.code === '4') {
// Product not available
showNotification({
type: 'error',
message: t('accountSettings.purchase.unavailable'),
})
} else if (error.code === '5') {
// Receipt already in use
showNotification({
type: 'error',
message: t('accountSettings.purchase.alreadyProcessed'),
})
} else if (error.code === '6') {
// Missing receipt file
showNotification({
type: 'error',
message: t('accountSettings.purchase.receiptMissing'),
})
} else if (error.code === '7') {
// Network error
showNotification({
type: 'error',
message: t('accountSettings.purchase.networkError'),
})
} else if (error.code === '8') {
// Invalid receipt
showNotification({
type: 'error',
message: t('accountSettings.purchase.invalidReceipt'),
})
} else if (error.code === '9') {
// Payment pending
showNotification({
type: 'warning',
message: t('accountSettings.purchase.pending'),
})
} else {
// Generic error
// log on what part of the code the error happened
console.error('Unexpected purchase error:', error)
console.error('Error occurred in purchase flow')
showNotification({
type: 'error',
message: t('accountSettings.purchase.failed', {
error:
error.message ||
t('accountSettings.purchase.unknownError'),
}),
})
}
}
} else {
setSubscriptionModal(true)
}
}}
>
{t('accountSettings.upgrade')}
</Button>
{userProfile?.subscription === 'active' && (
<Button
sx={{
width: '110px',
mb: 1,
ml: 1,
}}
variant='outlined'
color='danger'
onClick={() => {
setNativeCancelModal(true)
}}
>
{t('accountSettings.cancel')}
</Button>
)}
</Box>
{import.meta.env.VITE_IS_SELF_HOSTED === 'true' && (
<Box>
<Typography level='title-md' mb={1}>
{t('accountSettings.password')}
</Typography>
<Typography mb={1} level='body-sm'></Typography>
<Button
variant='soft'
onClick={() => {
setChangePasswordModal(true)
}}
>
{t('accountSettings.changePassword')}
</Button>
{changePasswordModal ? (
<PassowrdChangeModal
isOpen={changePasswordModal}
onClose={password => {
if (password) {
UpdatePassword(password).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: t('accountSettings.passwordChanged'),
})
} else {
showNotification({
type: 'error',
message: t('accountSettings.passwordChangeFailed'),
})
}
})
}
setChangePasswordModal(false)
}}
/>
) : null}
</Box>
)}
<Box>
<Typography level='title-md' mb={1} color='danger'>
{t('accountSettings.dangerZone')}
</Typography>
<Typography level='body-sm' mb={2} color='neutral'>
{t('accountSettings.dangerZoneDescription')}
</Typography>
<Button
variant='outlined'
color='danger'
onClick={() => setUserDeletionModal(true)}
>
{t('accountSettings.deleteAccount')}
</Button>
</Box>
</div>
<NotificationSetting />
<MFASettings />
<APITokenSettings />
<StorageSettings />
<div className='grid gap-4 py-4' id='sidepanel'>
<Typography level='h3'>{t('sidepanel.title')}</Typography>
<Divider />
<Typography level='body-md'>
{t('sidepanel.detailedDescription')}
</Typography>
<SidepanelSettings />
</div>
<div className='grid gap-4 py-4' id='theme'>
<Typography level='h3'>{t('theme.title')}</Typography>
<Divider />
<Typography level='body-md'>{t('theme.description')}</Typography>
<ThemeToggle />
</div>
<div className='grid gap-4 py-4' id='localization'>
<Typography level='h3'>{t('localization.title')}</Typography>
<Divider />
<Typography level='body-md'>
{t('localization.descriptionLong')}
</Typography>
<LocalizationSettings />
</div>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
<SubscriptionModal
open={subscriptionModal}
onClose={() => setSubscriptionModal(false)}
/>
<UserDeletionModal
isOpen={userDeletionModal}
onClose={success => {
setUserDeletionModal(false)
if (success) {
showNotification({
type: 'success',
message: 'Account deleted successfully',
})
}
}}
userProfile={userProfile}
/>
<NativeCancelSubscriptionModal
isOpen={nativeCancelModal}
onClose={action => {
setNativeCancelModal(false)
if (action === 'desktop') {
CancelSubscription().then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Subscription cancelled',
})
window.location.reload()
} else {
showNotification({
type: 'error',
message: 'Failed to cancel subscription',
})
}
})
}
}}
/>
</Container>
)
}
export default Settings