Refactor StorageSettings to use SettingsLayout and improve layout structure; add ThemeSettings component; update TermsView with new terms and structure; enhance ThingsView styling; clean up AddTaskModal by removing experimental feature chip; create CalendarCard component for calendar overview; optimize CustomParsers for repeat parsing; update NavBar for improved navigation and search parameter handling.

This commit is contained in:
Mo Tarbin
2025-09-11 01:17:27 -04:00
parent b152da9d21
commit 43decb21f4
38 changed files with 2934 additions and 1388 deletions

View File

@@ -7,8 +7,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 10
versionName "1.0.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -355,12 +355,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -377,12 +377,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

View File

@@ -1,58 +1,58 @@
PODS:
- Alamofire (5.10.2)
- AppAuth (1.7.6):
- AppAuth/Core (= 1.7.6)
- AppAuth/ExternalUserAgent (= 1.7.6)
- AppAuth/Core (1.7.6)
- AppAuth/ExternalUserAgent (1.7.6):
- AppAuth (2.0.0):
- AppAuth/Core (= 2.0.0)
- AppAuth/ExternalUserAgent (= 2.0.0)
- AppAuth/Core (2.0.0)
- AppAuth/ExternalUserAgent (2.0.0):
- AppAuth/Core
- AppCheckCore (11.2.0):
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- Capacitor (7.4.0):
- Capacitor (7.4.3):
- CapacitorCordova
- CapacitorApp (7.0.1):
- CapacitorApp (7.0.2):
- Capacitor
- CapacitorBrowser (7.0.2):
- Capacitor
- CapacitorCommunitySqlite (7.0.0):
- CapacitorCommunitySqlite (7.0.1):
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (7.4.0)
- CapacitorDevice (7.0.1):
- CapacitorCordova (7.4.3)
- CapacitorDevice (7.0.2):
- Capacitor
- CapacitorLocalNotifications (7.0.1):
- CapacitorLocalNotifications (7.0.2):
- Capacitor
- CapacitorNetwork (7.0.1):
- CapacitorNetwork (7.0.2):
- Capacitor
- CapacitorPluginSafeArea (4.0.0):
- Capacitor
- CapacitorPreferences (7.0.1):
- CapacitorPreferences (7.0.2):
- Capacitor
- CapacitorPushNotifications (7.0.1):
- CapacitorPushNotifications (7.0.2):
- Capacitor
- CapacitorStatusBar (7.0.1):
- CapacitorStatusBar (7.0.2):
- Capacitor
- CapgoCapacitorSocialLogin (7.5.3):
- CapgoCapacitorSocialLogin (7.11.2):
- Alamofire (~> 5.10.2)
- Capacitor
- FBSDKCoreKit (= 17.4.0)
- FBSDKLoginKit (= 17.4.0)
- GoogleSignIn (~> 8.0.0)
- FBAEMKit (17.4.0):
- FBSDKCoreKit_Basics (= 17.4.0)
- FBSDKCoreKit (17.4.0):
- FBAEMKit (= 17.4.0)
- FBSDKCoreKit_Basics (= 17.4.0)
- FBSDKCoreKit_Basics (17.4.0)
- FBSDKLoginKit (17.4.0):
- FBSDKCoreKit (= 17.4.0)
- GoogleSignIn (8.0.0):
- AppAuth (< 2.0, >= 1.7.3)
- FBSDKCoreKit (= 18.0.0)
- FBSDKLoginKit (= 18.0.0)
- GoogleSignIn (~> 9.0.0)
- FBAEMKit (18.0.0):
- FBSDKCoreKit_Basics (= 18.0.0)
- FBSDKCoreKit (18.0.0):
- FBAEMKit (= 18.0.0)
- FBSDKCoreKit_Basics (= 18.0.0)
- FBSDKCoreKit_Basics (18.0.0)
- FBSDKLoginKit (18.0.0):
- FBSDKCoreKit (= 18.0.0)
- GoogleSignIn (9.0.0):
- AppAuth (~> 2.0)
- AppCheckCore (~> 11.0)
- GTMAppAuth (< 5.0, >= 4.1.1)
- GTMAppAuth (~> 5.0)
- GTMSessionFetcher/Core (~> 3.3)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
@@ -63,29 +63,29 @@ PODS:
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (4.1.1):
- AppAuth/Core (~> 1.7)
- GTMAppAuth (5.0.0):
- AppAuth/Core (~> 2.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher/Core (3.5.0)
- PromisesObjC (2.4.0)
- PurchasesHybridCommon (16.2.0):
- RevenueCat (= 5.34.0)
- PurchasesHybridCommonUI (16.2.0):
- PurchasesHybridCommon (= 16.2.0)
- RevenueCatUI (= 5.34.0)
- RevenueCat (5.34.0)
- RevenuecatPurchasesCapacitor (11.1.1):
- PurchasesHybridCommon (17.0.0):
- RevenueCat (= 5.35.1)
- PurchasesHybridCommonUI (17.0.0):
- PurchasesHybridCommon (= 17.0.0)
- RevenueCatUI (= 5.35.1)
- RevenueCat (5.35.1)
- RevenuecatPurchasesCapacitor (11.1.2):
- Capacitor
- PurchasesHybridCommon (= 16.2.0)
- RevenuecatPurchasesCapacitorUi (11.1.1):
- PurchasesHybridCommon (= 17.0.0)
- RevenuecatPurchasesCapacitorUi (11.1.2):
- Capacitor
- PurchasesHybridCommonUI (= 16.2.0)
- RevenueCatUI (5.34.0):
- RevenueCat (= 5.34.0)
- SQLCipher (4.7.0):
- SQLCipher/standard (= 4.7.0)
- SQLCipher/common (4.7.0)
- SQLCipher/standard (4.7.0):
- PurchasesHybridCommonUI (= 17.0.0)
- RevenueCatUI (5.35.1):
- RevenueCat (= 5.35.1)
- SQLCipher (4.10.0):
- SQLCipher/standard (= 4.10.0)
- SQLCipher/common (4.10.0)
- SQLCipher/standard (4.10.0):
- SQLCipher/common
- ZIPFoundation (0.9.19)
@@ -161,37 +161,37 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
Capacitor: 10feab36396883c55373328124a677f118cd4b5f
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
Capacitor: 28d6c01026a9a3f7156529498ec1f389a2a28dbc
CapacitorApp: 1f6922c9c5c8b1c538d7fbe92ebe44a81b34bed3
CapacitorBrowser: 22541e48442de44dc629c214388290d6eecc6ae9
CapacitorCommunitySqlite: b11e556cf5d149f9e0b6fbbb47959a6cd718c3d0
CapacitorCordova: 723e017d8a80ab2ede9959fbe37556c35ac860d5
CapacitorDevice: c6f6d587dd310527f8a48bf09c4e7b4a4cf14329
CapacitorLocalNotifications: c2212755b33d2513b8bb325096ce3a64d4039ae3
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
CapacitorCommunitySqlite: 8b2c6bab33e3519280811d481f8bd0fa90343e1b
CapacitorCordova: 435121e81a2df4d0034f0fb11fcefab5104cfdb5
CapacitorDevice: 81ae78d5d1942707caad79276badd458bf6ec603
CapacitorLocalNotifications: 665188ae8accd40806129073896fb2b39322d858
CapacitorNetwork: 695069886b3c5ed514db69aa3d026b8dc3c03a6b
CapacitorPluginSafeArea: 22031c3436269ca80fac90ec2c94bc7c1e59a81d
CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
CapacitorPushNotifications: 6a2794788c583dd89215f1805ca4bced1b13dbdf
CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
CapgoCapacitorSocialLogin: 001d85f081a5167bde4ec4456353dbe4c03d4cd2
FBAEMKit: 58cb5f302cdd715a56d4c1d0dfdd2e423ac1421a
FBSDKCoreKit: 94d7461d0cecf441b1ba7c41acfff41daa8ccd41
FBSDKCoreKit_Basics: 151b43db8b834d3f0e02f95d36a44ffd36265e45
FBSDKLoginKit: 5c1cd53c91a2282b3a4fe6e6d3dcf2b8b0d33d55
GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4
CapacitorPreferences: 65107ed7437d96ee72583df5763985e3c0ff2bc2
CapacitorPushNotifications: 7c0659b349b149ee3936a682da31a9d269de9582
CapacitorStatusBar: e04d05e121d5a5979c29eb4249186a4e4a84cacb
CapgoCapacitorSocialLogin: a320106e1d032da88576cf8b31a336e971f5acad
FBAEMKit: e34530df538b8eb8aeb53c35867715ba6c63ef0c
FBSDKCoreKit: d3f479a69127acebb1c6aad91c1a33907bcf6c2f
FBSDKCoreKit_Basics: 017b6dc2a1862024815a8229e75661e627ac1e29
FBSDKLoginKit: 5875762d1fe09ddcb05d03365d4f5dc34413843d
GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PurchasesHybridCommon: 55e97b4e090015ed138988e6559def2bd4c50ebe
PurchasesHybridCommonUI: 25c66f6ddcf8856ecbcffee897b2525eb272a5e7
RevenueCat: eb2aa042789d9c99ad5172bd96e28b96286d6ada
RevenuecatPurchasesCapacitor: 420b762736c2842bfb64436b7e0b88d2f672c943
RevenuecatPurchasesCapacitorUi: 0cf343ae4e1c8b30b294c971a50827316b210a63
RevenueCatUI: 03025a80b64ce8ff8e43f47a7eb9dbbb6b4f486e
SQLCipher: ba9d0076041ed767c5bd3d3f77098318d04a403c
PurchasesHybridCommon: 34d2f73712f4c02f2963fcfb62d43757a9136860
PurchasesHybridCommonUI: 16e162423c0010f9a6f2c1d6505dcc075f49dcbe
RevenueCat: 47d52620419dcf3dce4e724eade9d390e16ab4b0
RevenuecatPurchasesCapacitor: 51cd81cec7db693a2552b45c36261d849e1cf574
RevenuecatPurchasesCapacitorUi: 267758d61aba42a0a3f035e6ba3c4630112357e4
RevenueCatUI: 3c72ef61b54c3f4787afab6a3b0f00cbec192063
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: 289063dee9cd9d5cc8abd18e13b4153b38864021

View File

@@ -23,6 +23,21 @@ const remove = className => {
// TODO: Update the interval to at 60 minutes
const intervalMS = 5 * 60 * 1000 // 5 minutes
const startOpenReplay = () => {
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
const tracker = new Tracker({
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
})
tracker.start()
}
const startApiManager = async navigate => {
await apiManager.init()
apiManager.setNavigateToLogin(() => {
navigate('/login')
})
}
const AppContent = () => {
const { showNotification } = useNotification()
@@ -126,20 +141,4 @@ function App() {
)
}
const startOpenReplay = () => {
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
const tracker = new Tracker({
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
})
tracker.start()
}
const startApiManager = navigate => {
apiManager.init()
apiManager.setNavigateToLogin(() => {
navigate('/login')
})
}
export default App

View File

@@ -702,7 +702,15 @@ const NotificationTemplate = ({
</Button>
</Box>
{showSaveDefault && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
mt: 1,
display: 'flex',
justifyContent: 'start',
alignItems: 'center',
gap: 1,
}}
>
<Button
variant='outlined'
size='sm'
@@ -723,7 +731,7 @@ const NotificationTemplate = ({
setShowSaveDefault(false)
}}
>
Save as Default
Save Preference
</Button>
</Box>
)}

View File

@@ -1,8 +1,14 @@
import App from '@/App'
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
import Error from '@/views/Error'
import AccountSettings from '@/views/Settings/AccountSettings'
import AdvancedSettings from '@/views/Settings/AdvancedSettings'
import CircleSettings from '@/views/Settings/CircleSettings'
import NotificationSettings from '@/views/Settings/NotificationSettings'
import Settings from '@/views/Settings/Settings'
import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
import ThemeSettings from '@/views/Settings/ThemeSettings'
import { Capacitor } from '@capacitor/core'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import AuthenticationLoading from '../views/Authorization/Authenticating'
@@ -15,12 +21,18 @@ import ChoreView from '../views/ChoreEdit/ChoreView'
import ArchivedTasks from '../views/Chores/ArchivedTasks'
import MyChores from '../views/Chores/MyChores'
import JoinCircleView from '../views/Circles/JoinCircle'
import NotFound from '../views/components/NotFound'
import ChoreHistory from '../views/History/ChoreHistory'
import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing'
import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
import APITokenSettings from '../views/Settings/APITokenSettings'
import MFASettings from '../views/Settings/MFASettings'
import ProfileSettings from '../views/Settings/ProfileSettings'
import SidepanelSettings from '../views/Settings/SidepanelSettings'
import StorageSettings from '../views/Settings/StorageSettings'
import TermsView from '../views/Terms/TermsView'
import TestView from '../views/TestView/Test'
import ThingsHistory from '../views/Things/ThingsHistory'
@@ -28,7 +40,6 @@ import ThingsView from '../views/Things/ThingsView'
import TimerDetails from '../views/Timer/TimerDetails'
import UserActivities from '../views/User/UserActivities'
import UserPoints from '../views/User/UserPoints'
import NotFound from '../views/components/NotFound'
const getMainRoute = () => {
if (
import.meta.env.VITE_IS_LANDING_DEFAULT === 'true' &&
@@ -50,11 +61,57 @@ const Router = createBrowserRouter([
},
{
path: '/settings',
element: <SettingsOverview />,
},
{
path: '/settings/detailed',
element: <Settings />,
element: <SettingsRoutes />,
children: [
{
index: true,
element: <SettingsOverview />,
},
{
path: 'detailed',
element: <Settings />,
},
{
path: 'profile',
element: <ProfileSettings />,
},
{
path: 'circle',
element: <CircleSettings />,
},
{
path: 'account',
element: <AccountSettings />,
},
{
path: 'notifications',
element: <NotificationSettings />,
},
{
path: 'mfa',
element: <MFASettings />,
},
{
path: 'apitokens',
element: <APITokenSettings />,
},
{
path: 'storage',
element: <StorageSettings />,
},
{
path: 'sidepanel',
element: <SidepanelSettings />,
},
{
path: 'theme',
element: <ThemeSettings />,
},
{
path: 'advanced',
element: <AdvancedSettings />,
},
],
},
{
path: '/chores',

View File

@@ -0,0 +1,39 @@
import { useState } from 'react'
const useAcknowledgmentModal = () => {
const [ackModalConfig, setAckModalConfig] = useState({})
const showAcknowledgment = (
message,
title,
onAcknowledge,
acknowledgeText = 'Got it',
color = 'primary',
) => {
setAckModalConfig({
isOpen: true,
message,
title,
acknowledgeText,
color,
onClose: () => {
if (onAcknowledge) {
onAcknowledge()
}
setAckModalConfig({})
},
})
}
const hideAcknowledgment = () => {
setAckModalConfig({})
}
return {
ackModalConfig,
showAcknowledgment,
hideAcknowledgment,
}
}
export default useAcknowledgmentModal

View File

@@ -10,21 +10,31 @@ class ApiManager {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
this.initialized = false
this.initPromise = null
this.navigateToLogin = () => {}
}
async init() {
if (this.initialized) {
return
if (this.initPromise) {
return this.initPromise
}
if (this.initialized) {
return Promise.resolve()
}
this.initPromise = this._doInit()
return this.initPromise
}
async _doInit() {
const { value: serverURL } = await Preferences.get({
key: 'customServerUrl',
})
this.customServerURL = `${serverURL || API_URL}/api/v1`
await localStore.initDatabase()
this.initialized = true
await localStore.initDatabase()
}
getApiURL() {
@@ -47,6 +57,8 @@ export const getAssetURL = path => {
return `${baseURL}/assets/${path}`
}
export async function UploadFile(url, options) {
await apiManager.init()
if (!isTokenValid()) {
Cookies.set('ca_redirect', window.location.pathname)
window.location.href = '/login'
@@ -65,6 +77,7 @@ export async function UploadFile(url, options) {
}
export async function Fetch(url, options) {
await apiManager.init()
if (!isTokenValid()) {
Cookies.set('ca_redirect', window.location.pathname)
if (window.location.pathname !== '/login') {
@@ -148,6 +161,7 @@ export const isTokenValid = () => {
}
return false
}
return false
}
export const refreshAccessToken = () => {

View File

@@ -425,6 +425,7 @@ const LoginView = () => {
label='Password'
type='password'
id='password'
autoComplete='password'
value={password}
onChange={e => {
setPassword(e.target.value)

View File

@@ -1,4 +1,4 @@
import { Add, HorizontalRule } from '@mui/icons-material'
import { Add, HorizontalRule, Save } from '@mui/icons-material'
import {
Box,
Button,
@@ -102,6 +102,10 @@ const ChoreEdit = () => {
const [errors, setErrors] = useState({})
const [attemptToSave, setAttemptToSave] = useState(false)
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
const [privacySaved, setPrivacySaved] = useState(false)
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
useState(false)
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
const updateChoreMutation = useUpdateChore()
@@ -272,6 +276,23 @@ const ChoreEdit = () => {
setAllUserThings(data.res)
})
})
// Load default privacy setting for new chores
if (!choreId) {
const defaultPrivacySetting = localStorage.getItem(
'defaultPrivacySetting',
)
if (defaultPrivacySetting !== null) {
setIsPrivate(JSON.parse(defaultPrivacySetting))
}
const defaultNotificationSetting = localStorage.getItem(
'defaultNotificationSetting',
)
if (defaultNotificationSetting !== null) {
setIsNotificable(JSON.parse(defaultNotificationSetting))
}
}
}, [])
useEffect(() => {
if (isChoreLoading === false && choreData && choreId) {
@@ -1091,7 +1112,9 @@ const ChoreEdit = () => {
name='isPrivate'
value={isPrivate}
onChange={event => {
setIsPrivate(event.target.value === 'true' ? true : false)
const newValue = event.target.value === 'true' ? true : false
setIsPrivate(newValue)
setShowSavePrivacyDefault(true)
}}
sx={{
'& > div': { py: 1 },
@@ -1108,6 +1131,33 @@ const ChoreEdit = () => {
</FormHelperText>
</FormControl>
</RadioGroup>
{showSavePrivacyDefault && (
<Box sx={{ mt: 0, display: 'flex', justifyContent: 'start' }}>
<Button
variant='outlined'
size='sm'
color='neutral'
startDecorator={<Save />}
sx={{
borderRadius: 6,
fontWeight: 500,
'&:hover': {
background: 'neutral.softHoverBg',
},
}}
onClick={() => {
localStorage.setItem(
'defaultPrivacySetting',
JSON.stringify(isPrivate),
)
setShowSavePrivacyDefault(false)
}}
>
Save Preference
</Button>
</Box>
)}
</Box>
</Box>

View File

@@ -254,12 +254,11 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '310px',
minHeight: 300,
width: '315px',
maxHeight: 400,
mb: 1,
}}
>
@@ -311,12 +310,11 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
// width: '290px',
width: '310px',
minHeight: 300,
maxHeight: 400,
mb: 1,
}}
>

View File

@@ -38,9 +38,10 @@ import {
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
import { useNotification } from '../../service/NotificationProvider'
import { TASK_COLOR } from '../../utils/Colors'
import { ArchiveChore } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
@@ -102,6 +103,7 @@ const MyChores = () => {
const [selectedCalendarDate, setSelectedCalendarDate] = useState(new Date())
const menuRef = useRef(null)
const Navigate = useNavigate()
const [searchParams] = useSearchParams()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const {
data: choresData,
@@ -126,14 +128,14 @@ const MyChores = () => {
) {
setPerformers(membersData.res)
let sortedChores = choresData.res.sort(ChoreSorter)
// Filter chores based on impersonated user
if (impersonatedUser) {
sortedChores = sortedChores.filter(chore =>
chore.assignedTo === impersonatedUser.userId
sortedChores = sortedChores.filter(
chore => chore.assignedTo === impersonatedUser.userId,
)
}
setChores(sortedChores)
setFilteredChores(sortedChores)
const sections = ChoresGrouper(
@@ -400,6 +402,72 @@ const MyChores = () => {
document.removeEventListener('keyup', handleKeyUp)
}
}, [isMultiSelectMode, selectedChores.size, addTaskModalOpen])
// Auto-update selected calendar date when day changes (large screen only)
useEffect(() => {
if (!isLargeScreen || viewMode !== 'calendar' || !selectedCalendarDate) {
return
}
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(0, 0, 0, 0) // Set to start of tomorrow
const msUntilTomorrow = tomorrow.getTime() - now.getTime()
const timeout = setTimeout(() => {
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
// Check if selected date is now yesterday and update to today
if (selectedCalendarDate.toDateString() === yesterday.toDateString()) {
setSelectedCalendarDate(today)
}
}, msUntilTomorrow)
return () => clearTimeout(timeout)
}, [isLargeScreen, viewMode, selectedCalendarDate])
// Handle URL parameters for navigation from calendar
useEffect(() => {
const filter = searchParams.get('filter')
const view = searchParams.get('view')
if (view === 'calendar') {
setViewMode('calendar')
setSearchFilter('All')
setSelectedCalendarDate(null)
return
}
if (filter && chores.length > 0) {
let filterKey = ''
let filteredChores = []
switch (filter) {
case 'overdue':
filterKey = 'Overdue'
filteredChores = FILTERS['Overdue'](chores)
break
case 'unplanned':
filterKey = 'No Due Date'
filteredChores = FILTERS['No Due Date'](chores)
break
case 'pending-approval':
filterKey = 'Pending Approval'
filteredChores = FILTERS['Pending Approval'](chores)
break
default:
return
}
setFilteredChores(filteredChores)
setSearchFilter(filterKey)
setViewMode('default')
}
}, [searchParams, chores])
const setSelectedChoreSectionWithCache = value => {
setSelectedChoreSection(value)
localStorage.setItem('selectedChoreSection', value)
@@ -454,16 +522,16 @@ const MyChores = () => {
if (searchTerm?.length > 0 || searchFilter !== 'All') {
return filteredChores
}
let choresToFilter = chores
// Filter by impersonated user first if set
if (impersonatedUser) {
choresToFilter = choresToFilter.filter(chore =>
chore.assignedTo === impersonatedUser.userId
choresToFilter = choresToFilter.filter(
chore => chore.assignedTo === impersonatedUser.userId,
)
}
return choresToFilter.filter(ChoreFilters(userProfile)[selectedChoreFilter])
}
@@ -480,14 +548,14 @@ const MyChores = () => {
const updateChores = newChore => {
let newChores = [...chores, newChore]
// Filter chores based on impersonated user
if (impersonatedUser) {
newChores = newChores.filter(chore =>
chore.assignedTo === impersonatedUser.userId
newChores = newChores.filter(
chore => chore.assignedTo === impersonatedUser.userId,
)
}
setChores(newChores)
setFilteredChores(newChores)
setChoreSections(
@@ -1658,6 +1726,128 @@ const MyChores = () => {
)}
{viewMode === 'calendar' && (
<>
{/* Summary Chips when no date selected */}
<Box
sx={{
mt: 1,
mb: 1,
display: 'flex',
gap: 1.5,
justifyContent: 'start',
flexWrap: 'wrap',
}}
>
{FILTERS['Overdue'](getFilteredChores()).length > 0 && (
<Chip
variant='soft'
color='danger'
size='lg'
onClick={() => {
// Update URL for navigation context
Navigate('/chores?filter=overdue', {
replace: false,
})
// Also update state directly for immediate smooth transition
const overdueChores =
FILTERS['Overdue'](getFilteredChores())
setFilteredChores(overdueChores)
setSearchFilter('Overdue')
setViewMode('default')
setSelectedCalendarDate(null)
}}
sx={{
cursor: 'pointer',
transition: 'all 0.2s ease',
px: 1,
py: 0.5,
}}
startDecorator={
<Chip size='md' variant='solid' color='danger'>
{FILTERS['Overdue'](getFilteredChores()).length}
</Chip>
}
>
Overdue
</Chip>
)}
{FILTERS['No Due Date'](getFilteredChores()).length > 0 && (
<Chip
variant='soft'
color='neutral'
size='lg'
onClick={() => {
// Update URL for navigation context
Navigate('/chores?filter=unplanned', {
replace: false,
})
// Also update state directly for immediate smooth transition
const unplannedChores =
FILTERS['No Due Date'](getFilteredChores())
setFilteredChores(unplannedChores)
setSearchFilter('No Due Date')
setViewMode('default')
setSelectedCalendarDate(null)
}}
sx={{
cursor: 'pointer',
transition: 'all 0.2s ease',
px: 1,
py: 0.5,
}}
startDecorator={
<Chip size='md' variant='solid' color='neutral'>
{FILTERS['No Due Date'](getFilteredChores()).length}
</Chip>
}
>
Unplanned
</Chip>
)}
{FILTERS['Pending Approval'](getFilteredChores()).length > 0 && (
<Chip
variant='soft'
size='lg'
onClick={() => {
// Update URL for navigation context
Navigate('/chores?filter=pending-approval', {
replace: true,
})
// Also update state directly for immediate smooth transition
const pendingApprovalChores =
FILTERS['Pending Approval'](getFilteredChores())
setFilteredChores(pendingApprovalChores)
setSearchFilter('Pending Approval')
setViewMode('default')
setSelectedCalendarDate(null)
}}
sx={{
cursor: 'pointer',
transition: 'all 0.2s ease',
px: 1,
py: 0.5,
}}
startDecorator={
<Chip
size='md'
variant='solid'
sx={{
bgcolor: TASK_COLOR.PENDING_REVIEW,
color: 'white',
}}
>
{FILTERS['Pending Approval'](getFilteredChores()).length}
</Chip>
}
>
Pending Approval
</Chip>
)}
</Box>
{/* Calendar Monthly View */}
<Box sx={{ mb: 2 }}>
{isLargeScreen ? (
@@ -1951,6 +2141,11 @@ const FILTERS = {
return chore.nextDueDate === null
})
},
'Pending Approval': function (chores) {
return chores.filter(chore => {
return chore.status === 3
})
},
}
export default MyChores

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { useChoresHistory } from '../../queries/ChoreQueries'
import { ChoresGrouper } from '../../utils/Chores'
import { getSidepanelConfig } from '../../utils/SidepanelConfig'
import CalendarView from '../components/CalendarView'
import CalendarCard from '../components/CalendarCard'
import ActivitiesCard from './ActivitesCard'
import TasksByAssigneeCard from './TasksByAssigneeCard'
import UserSwitcher from './UserSwitcher'
@@ -78,7 +78,7 @@ const Sidepanel = ({ chores }) => {
<Box
sx={{ width: '100%', overflowY: 'hidden', overflowX: 'hidden' }}
>
<CalendarView chores={chores} />
<CalendarCard chores={chores} />
</Box>
</Sheet>
)

View File

@@ -2,11 +2,19 @@ import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
import Logo from '../../Logo'
import { Button } from '@mui/joy'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { JoinCircle } from '../../utils/Fetcher'
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
const JoinCircleView = () => {
const { data: userProfile } = useUserProfile()
const { showError } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
const [isJoining, setIsJoining] = useState(false)
let [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
@@ -79,25 +87,31 @@ const JoinCircleView = () => {
fullWidth
size='lg'
sx={{ mt: 3, mb: 2 }}
disabled={isJoining}
onClick={() => {
setIsJoining(true)
JoinCircle(code).then(resp => {
if (resp.ok) {
alert(
'Joined circle successfully, wait for the circle owner to accept your request.',
showAcknowledgment(
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
'Join Request Sent!',
() => navigate('/'),
'Got it',
'success',
)
navigate('/chores')
} else {
setIsJoining(false)
if (resp.status === 409) {
alert('You are already a member of this circle')
showError('You are already a member of this circle')
} else {
alert('Failed to join circle')
showError('Failed to join circle')
}
navigate('/chores')
navigate('/')
}
})
}}
>
Join Circle
{isJoining ? 'Joining...' : 'Join Circle'}
</Button>
<Button
fullWidth
@@ -141,6 +155,7 @@ const JoinCircleView = () => {
))}
</Sheet>
</Box>
<AcknowledgmentModal config={ackModalConfig} />
</Container>
)
}

View File

@@ -553,7 +553,7 @@ const LabelView = () => {
return (
<Container maxWidth='md' sx={{ px: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
<Stack sx={{ flex: 1 }}>
<Typography

View File

@@ -19,7 +19,7 @@ const HomeHero = () => {
const windowThreshold = 600
const { mode, setMode } = useColorScheme()
const HERO_TEXT_THAT = [
'An open-source, user-friendly app for managing tasks and chores, featuring customizable options to help you and others stay organized',
'The task manager that helps familiesand individuals stay on top of chores with smart scheduling, fair assignments, and powerful automation.',
]
const [heroTextIndex, setHeroTextIndex] = React.useState(0)

View File

@@ -1,7 +1,6 @@
/* eslint-disable react/jsx-key */
import { CheckRounded } from '@mui/icons-material'
import { Box, Button, Card, Container, Typography } from '@mui/joy'
import React from 'react'
import { Container, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom'
const PricingSection = () => {
@@ -67,111 +66,11 @@ const PricingSection = () => {
<Typography level='h4' mt={2} mb={2}>
Pricing
</Typography>
<Container maxWidth={'sm'} sx={{ mb: 8 }}>
<Typography level='body-md' color='neutral'>
Choose the plan that works best for you.
</Typography>
</Container>
<div
className='mt-8 grid grid-cols-1 gap-2 sm:grid-cols-1 lg:grid-cols-2'
data-aos-id-pricing
>
{PRICEITEMS.map((pi, index) => (
<Card
key={index}
data-aos-delay={50 * (1 + index)}
data-aos-anchor='[data-aos-id-pricing]'
data-aos='fade-up'
className='hover:bg-white dark:hover:bg-teal-900'
sx={{
textAlign: 'center',
p: 5,
minHeight: 400,
// maxWidth: 400,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
// when top reach the top change the background color:
'&:hover': {
// backgroundColor: '#FFFFFF',
boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)',
},
}}
>
<Box
display='flex'
flexDirection='column'
justifyContent='flex-start' // Updated property
alignItems='center'
>
<Typography level='h2'>{pi.title}</Typography>
<Typography level='body-md'>{pi.description}</Typography>
</Box>
<Box
display='flex'
flexDirection='column'
justifyContent='center'
alignItems='center'
>
<Box
display='flex'
flexDirection='row'
alignItems='baseline'
sx={{ my: 4 }}
>
{pi.discount && (
<Typography
level='h3'
component='span'
sx={{ textDecoration: 'line-through', opacity: 0.5 }}
>
${pi.previousPrice}&nbsp;
</Typography>
)}
<Typography level='h2' component='span'>
${pi.price}
</Typography>
<Typography level='body-md' component='span'>
/ {pi.interval}
</Typography>
</Box>
<Typography level='title-md'>Features</Typography>
{pi.features.map(feature => (
<Typography
startDecorator={feature[1]}
level='body-md'
color='neutral'
lineHeight={1.6}
>
{feature[0]}
</Typography>
))}
{/* Here start the test */}
<div style={{ marginTop: 'auto' }}>
<Button
sx={{ mt: 5 }}
onClick={() => {
navigate('/settings#account')
}}
>
Get Started
</Button>
<Typography
level='body-md'
color='neutral'
lineHeight={1.6}
></Typography>
</div>
</Box>
</Card>
))}
</div>
{/* Here start the test */}
</Container>
)
}

View File

@@ -0,0 +1,108 @@
import { Box, Button, Typography } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function AcknowledgmentModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const handleAction = useCallback(() => {
config.onClose()
}, [config])
// Keyboard shortcuts for acknowledgment modal
useEffect(() => {
const handleKeyDown = event => {
if (!config?.isOpen) return
// Show keyboard shortcuts when Ctrl/Cmd is pressed
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
// Ctrl/Cmd + Y for acknowledge
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
event.preventDefault()
handleAction()
return
}
// Escape key for acknowledge
if (event.key === 'Escape') {
event.preventDefault()
handleAction()
return
}
// Enter key for acknowledge
if (event.key === 'Enter') {
event.preventDefault()
handleAction()
return
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
}
if (config?.isOpen) {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [config?.isOpen, handleAction])
return (
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
size='md'
unmountDelay={250}
>
<Box
sx={{ p: 2, minWidth: { xs: '100%', sm: '400px' }, maxWidth: '500px' }}
>
<Typography level='h4' mb={2} textAlign='center'>
{config?.title}
</Typography>
<Typography
level='body-md'
mb={3}
sx={{
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{config?.message}
</Typography>
<Box display={'flex'} justifyContent={'center'} mt={2}>
<Button
size='lg'
onClick={handleAction}
color={config?.color || 'primary'}
fullWidth
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
sx={{ minWidth: '120px' }}
>
{config?.acknowledgeText}
</Button>
</Box>
</Box>
</ResponsiveModal>
)
}
export default AcknowledgmentModal

View File

@@ -22,6 +22,7 @@ import {
import { isPlusAccount } from '../../utils/Helpers'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import TextModal from '../Modals/Inputs/TextModal'
import SettingsLayout from './SettingsLayout'
const APITokenSettings = () => {
const { data: userProfile } = useUserProfile()
@@ -77,142 +78,144 @@ const APITokenSettings = () => {
}
return (
<div className='grid gap-4 py-4' id='apitokens'>
<Typography level='h3'>Access Token</Typography>
<Divider />
<Typography level='body-sm'>
Create token to use with the API to update things that trigger task or
chores
</Typography>
{!isPlusAccount(userProfile) && (
<>
<Chip variant='soft' color='warning'>
Plus Feature
</Chip>
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
API tokens are not available in the Basic plan. Upgrade to Plus to
generate API tokens for integrating with external systems and
automating your tasks.
</Typography>
</>
)}
<SettingsLayout title='API Tokens'>
<div className='grid gap-4 py-4' id='apitokens'>
<Typography level='h3'>Access Token</Typography>
<Divider />
<Typography level='body-sm'>
Create token to use with the API to update things that trigger task or
chores
</Typography>
{!isPlusAccount(userProfile) && (
<>
<Chip variant='soft' color='warning'>
Plus Feature
</Chip>
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
API tokens are not available in the Basic plan. Upgrade to Plus to
generate API tokens for integrating with external systems and
automating your tasks.
</Typography>
</>
)}
{tokens.map(token => (
<Card key={token.token} className='p-4'>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box>
<Typography level='body-md'>{token.name}</Typography>
<Typography level='body-xs'>
{moment(token.createdAt).fromNow()}(
{moment(token.createdAt).format('lll')})
</Typography>
</Box>
<Box>
<Button
variant='outlined'
color='primary'
sx={{ mr: 1 }}
onClick={() => {
if (showTokenId === token.id) {
setShowTokenId(null)
return
}
setShowTokenId(token.id)
}}
>
{showTokenId === token?.id ? 'Hide' : 'Show'} Token
</Button>
<Button
variant='outlined'
color='danger'
onClick={() => {
showConfirmation(
`Are you sure you want to remove ${token.name}?`,
'Remove Token',
() => {
DeleteLongLiveToken(token.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
title: 'Removed',
message: 'API token has been removed',
})
const newTokens = tokens.filter(
t => t.id !== token.id,
)
setTokens(newTokens)
}
})
},
'Remove',
'Cancel',
'danger',
)
}}
>
Remove
</Button>
</Box>
</Box>
{showTokenId === token?.id && (
<Box>
<Input
value={token.token}
sx={{ width: '100%', mt: 2 }}
readOnly
endDecorator={
<IconButton
variant='outlined'
color='primary'
onClick={() => {
navigator.clipboard.writeText(token.token)
showNotification({
type: 'success',
message: 'Token copied to clipboard',
})
{tokens.map(token => (
<Card key={token.token} className='p-4'>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box>
<Typography level='body-md'>{token.name}</Typography>
<Typography level='body-xs'>
{moment(token.createdAt).fromNow()}(
{moment(token.createdAt).format('lll')})
</Typography>
</Box>
<Box>
<Button
variant='outlined'
color='primary'
sx={{ mr: 1 }}
onClick={() => {
if (showTokenId === token.id) {
setShowTokenId(null)
}}
>
<CopyAll />
</IconButton>
}
/>
return
}
setShowTokenId(token.id)
}}
>
{showTokenId === token?.id ? 'Hide' : 'Show'} Token
</Button>
<Button
variant='outlined'
color='danger'
onClick={() => {
showConfirmation(
`Are you sure you want to remove ${token.name}?`,
'Remove Token',
() => {
DeleteLongLiveToken(token.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
title: 'Removed',
message: 'API token has been removed',
})
const newTokens = tokens.filter(
t => t.id !== token.id,
)
setTokens(newTokens)
}
})
},
'Remove',
'Cancel',
'danger',
)
}}
>
Remove
</Button>
</Box>
</Box>
)}
</Card>
))}
{showTokenId === token?.id && (
<Box>
<Input
value={token.token}
sx={{ width: '100%', mt: 2 }}
readOnly
endDecorator={
<IconButton
variant='outlined'
color='primary'
onClick={() => {
navigator.clipboard.writeText(token.token)
showNotification({
type: 'success',
message: 'Token copied to clipboard',
})
setShowTokenId(null)
}}
>
<CopyAll />
</IconButton>
}
/>
</Box>
)}
</Card>
))}
<Button
variant='soft'
color='primary'
disabled={!isPlusAccount(userProfile)}
sx={{
width: '210px',
mb: 1,
}}
onClick={() => {
setIsGetTokenNameModalOpen(true)
}}
>
Generate New Token
</Button>
<TextModal
isOpen={isGetTokenNameModalOpen}
title='Give a name for your new token, something to remember it by.'
onClose={() => {
setIsGetTokenNameModalOpen(false)
}}
okText={'Generate Token'}
onSave={handleSaveToken}
/>
<Button
variant='soft'
color='primary'
disabled={!isPlusAccount(userProfile)}
sx={{
width: '210px',
mb: 1,
}}
onClick={() => {
setIsGetTokenNameModalOpen(true)
}}
>
Generate New Token
</Button>
<TextModal
isOpen={isGetTokenNameModalOpen}
title='Give a name for your new token, something to remember it by.'
onClose={() => {
setIsGetTokenNameModalOpen(false)
}}
okText={'Generate Token'}
onSave={handleSaveToken}
/>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</div>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</div>
</SettingsLayout>
)
}

View File

@@ -0,0 +1,308 @@
import { Capacitor } from '@capacitor/core'
import { Box, Button, Container, Divider, 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 SubscriptionModal from '../../components/SubscriptionModal'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { CancelSubscription, UpdatePassword } from '../../utils/Fetcher'
import NativeCancelSubscriptionModal from '../Modals/Inputs/NativeCancelSubscriptionModal'
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
import SettingsLayout from './SettingsLayout'
const AccountSettings = () => {
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const [changePasswordModal, setChangePasswordModal] = useState(false)
const [subscriptionModal, setSubscriptionModal] = useState(false)
const [userDeletionModal, setUserDeletionModal] = useState(false)
const [nativeCancelModal, setNativeCancelModal] = useState(false)
useEffect(() => {
async function configurePurchases() {
if (Capacitor.isNativePlatform() && userProfile) {
await Purchases.configure({
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
appUserID: String(userProfile?.id),
})
}
}
configurePurchases()
}, [userProfile])
const getSubscriptionDetails = () => {
if (userProfile?.subscription === 'active') {
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${moment(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
} else if (userProfile?.subscription === 'cancelled') {
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${moment(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
} else {
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
}
}
const getSubscriptionStatus = () => {
if (userProfile?.subscription === 'active') {
return `Plus`
} else if (userProfile?.subscription === 'cancelled') {
if (moment().isBefore(userProfile?.expiration)) {
return `Plus(until ${moment(userProfile?.expiration).format(
'MMM DD, YYYY',
)})`
}
return `Free`
} else {
return `Free`
}
}
if (!userProfile) {
return (
<SettingsLayout title="Account Settings">
<div>Loading...</div>
</SettingsLayout>
)
}
return (
<SettingsLayout title="Account Settings">
<div className='grid gap-4'>
<Typography level='body-md'>
Change your account settings, type or update your password
</Typography>
<Typography level='title-md' mb={-1}>
Account 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,
})
const { customerInfo } = await Purchases.getCustomerInfo()
if (customerInfo.entitlements.active['Donetick Plus']) {
queryClient.invalidateQueries(['userProfile'])
queryClient.refetchQueries(['userProfile'])
showNotification({
type: 'success',
message:
'Purchase successful! Please restart the app to access Plus features.',
})
}
} catch (error) {
console.log('Purchase error:', error)
if (error.code === '1') {
return
} else if (error.code === '2') {
showNotification({
type: 'error',
message:
'Store connection issue. Please check your network and try again.',
})
} else if (error.code === '3') {
showNotification({
type: 'error',
message:
'Purchases are not allowed on this device. Please check your device restrictions.',
})
} else if (error.code === '4') {
showNotification({
type: 'error',
message:
'This subscription is not available. Please try again later.',
})
} else if (error.code === '5') {
showNotification({
type: 'error',
message:
'This purchase has already been processed. If you believe this is an error, please contact support.',
})
} else if (error.code === '6') {
showNotification({
type: 'error',
message:
'Purchase receipt missing. Please try purchasing again.',
})
} else if (error.code === '7') {
showNotification({
type: 'error',
message:
'Network error. Please check your connection and try again.',
})
} else if (error.code === '8') {
showNotification({
type: 'error',
message:
'Invalid purchase receipt. Please contact support if this persists.',
})
} else if (error.code === '9') {
showNotification({
type: 'warning',
message:
'Payment is pending approval. You will receive access once approved.',
})
} else {
console.error('Unexpected purchase error:', error)
console.error('Error occurred in purchase flow')
showNotification({
type: 'error',
message: `Purchase failed: ${error.message || 'Unknown error'}. Please try again or contact support.`,
})
}
}
} else {
setSubscriptionModal(true)
}
}}
>
Upgrade
</Button>
{userProfile?.subscription === 'active' && (
<Button
sx={{
width: '110px',
mb: 1,
ml: 1,
}}
variant='outlined'
color='danger'
onClick={() => {
setNativeCancelModal(true)
}}
>
Cancel
</Button>
)}
</Box>
{import.meta.env.VITE_IS_SELF_HOSTED === 'true' && (
<Box>
<Typography level='title-md' mb={1}>
Password :
</Typography>
<Typography mb={1} level='body-sm'></Typography>
<Button
variant='soft'
onClick={() => {
setChangePasswordModal(true)
}}
>
Change Password
</Button>
{changePasswordModal ? (
<PassowrdChangeModal
isOpen={changePasswordModal}
onClose={password => {
if (password) {
UpdatePassword(password).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Password changed successfully',
})
} else {
showNotification({
type: 'error',
message: 'Password change failed',
})
}
})
}
setChangePasswordModal(false)
}}
/>
) : null}
</Box>
)}
<Box>
<Typography level='title-md' mb={1} color='danger'>
Danger Zone
</Typography>
<Typography level='body-sm' mb={2} color='neutral'>
Once you delete your account, there is no going back. Please be
certain.
</Typography>
<Button
variant='outlined'
color='danger'
onClick={() => setUserDeletionModal(true)}
>
Delete Account
</Button>
</Box>
</div>
<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',
})
}
})
}
}}
/>
</SettingsLayout>
)
}
export default AccountSettings

View File

@@ -0,0 +1,163 @@
import {
Box,
Button,
Card,
Checkbox,
Chip,
FormControl,
FormHelperText,
Input,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import RealTimeSettings from '../../components/RealTimeSettings'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { GetUserCircle, PutWebhookURL } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import SettingsLayout from './SettingsLayout'
const AdvancedSettings = () => {
const { data: userProfile } = useUserProfile()
const { showNotification } = useNotification()
const [userCircles, setUserCircles] = useState([])
const [webhookURL, setWebhookURL] = useState(null)
const [webhookError, setWebhookError] = useState(null)
const [isAdmin, setIsAdmin] = useState(false)
useEffect(() => {
GetUserCircle().then(resp => {
resp.json().then(data => {
setUserCircles(data.res ? data.res : [])
setWebhookURL(data.res ? data.res[0].webhook_url : null)
})
})
}, [])
// Check if user is admin based on userRole from the circle data
useEffect(() => {
if (userCircles.length > 0) {
setIsAdmin(userCircles[0]?.userRole === 'admin')
}
}, [userCircles])
if (!userProfile) {
return (
<SettingsLayout title="Advanced Settings">
<div>Loading...</div>
</SettingsLayout>
)
}
return (
<SettingsLayout title="Advanced Settings">
<div className='grid gap-4'>
<Typography level='body-md'>
Configure advanced features like webhooks and real-time updates for enhanced productivity.
</Typography>
{/* Webhook Settings - Only show for admins */}
{isAdmin && (
<>
<Typography level='title-lg' mt={2}>
Webhook Integration
</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.
</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.
</Typography>
)}
<FormControl sx={{ mt: 1 }}>
<Checkbox
checked={webhookURL !== null}
onClick={() => {
if (webhookURL === null) {
setWebhookURL('')
} else {
setWebhookURL(null)
}
}}
variant='soft'
label='Enable Webhook'
disabled={!isPlusAccount(userProfile)}
overlay
/>
<FormHelperText
sx={{
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
}}
>
Enable webhook notifications for tasks and things updates.{' '}
{userProfile && !isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning'>
Plus Feature
</Chip>
)}
</FormHelperText>
</FormControl>
{webhookURL !== null && (
<Box>
<Typography level='title-sm'>Webhook URL</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: 'Webhook URL updated successfully',
})
} else {
showNotification({
type: 'error',
message: 'Failed to update webhook URL',
})
}
})
}}
disabled={!isPlusAccount(userProfile)}
>
Save
</Button>
</Box>
)}
</>
)}
{/* Real-time Settings */}
<Typography level='title-lg' mt={2}>
Real-time Updates
</Typography>
<Typography level='body-md' mt={-1}>
Configure how you receive live updates when tasks and activities change in your circle.
</Typography>
<RealTimeSettings />
</div>
</SettingsLayout>
)
}
export default AdvancedSettings

View File

@@ -0,0 +1,489 @@
import { Delete, Refresh } from '@mui/icons-material'
import {
Box,
Button,
Card,
Chip,
CircularProgress,
Container,
Divider,
Input,
Option,
Select,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
AcceptCircleMemberRequest,
DeleteCircleMember,
GetAllCircleMembers,
GetCircleMemberRequests,
GetUserCircle,
JoinCircle,
LeaveCircle,
UpdateMemberRole,
} from '../../utils/Fetcher'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import SettingsLayout from './SettingsLayout'
const CircleSettings = () => {
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const navigate = useNavigate()
const [userCircles, setUserCircles] = useState([])
const [circleMemberRequests, setCircleMemberRequests] = useState([])
const [circleInviteCode, setCircleInviteCode] = useState('')
const [circleMembers, setCircleMembers] = useState([])
const [isAdmin, setIsAdmin] = useState(false)
const [lastRefresh, setLastRefresh] = useState(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = '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: 'Failed to refresh member requests',
})
} finally {
setIsRefreshing(false)
}
}
useEffect(() => {
GetUserCircle().then(resp => {
resp.json().then(data => {
setUserCircles(data.res ? data.res : [])
})
})
GetCircleMemberRequests().then(resp => {
resp.json().then(data => {
setCircleMemberRequests(data.res ? data.res : [])
setLastRefresh(new Date())
})
})
GetAllCircleMembers().then(data => {
setCircleMembers(data.res ? data.res : [])
})
}, [])
useEffect(() => {
if (userProfile && userProfile.id) {
const isUserAdmin = circleMembers.some(
member => member.userId === userProfile.id && member.role === 'admin',
)
setIsAdmin(isUserAdmin)
}
}, [circleMembers, userProfile])
if (!userProfile) {
return (
<SettingsLayout title="Circle Settings">
<CircularProgress />
</SettingsLayout>
)
}
return (
<SettingsLayout title="Circle Settings">
<div className='grid gap-4'>
<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.
</Typography>
<Typography level='title-sm' mb={-1}>
{userCircles[0]?.userRole === 'member'
? `You part of ${userCircles[0]?.name} `
: `You circle code is:`}
<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: 'Code copied to clipboard',
})
}}
>
Copy Code
</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: 'Link copied to clipboard',
})
}}
>
Copy Link
</Button>
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
<Button
color='danger'
variant='outlined'
sx={{ ml: 1 }}
onClick={() => {
showConfirmation(
'Are you sure you want to leave your circle?',
'Leave Circle',
() => {
LeaveCircle(userCircles[0]?.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Left circle successfully',
})
} else {
showNotification({
type: 'error',
message: 'Failed to leave circle',
})
}
})
},
'Leave',
'Cancel',
'danger',
)
}}
>
Leave Circle
</Button>
)}
</Typography>
<Typography level='title-md'>Circle Members</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 ? '(You)' : ''}{' '}
<Chip>
{' '}
{member.isActive ? member.role : 'Pending Approval'}
</Chip>
</Typography>
{member.isActive ? (
<Typography level='body-sm'>
Joined on {moment(member.createdAt).format('MMM DD, YYYY')}
</Typography>
) : (
<Typography level='body-sm' color='danger'>
Request to join{' '}
{moment(member.updatedAt).format('MMM DD, YYYY')}
</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: 'Failed to update role',
})
}
})
}}
>
{[
{
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}>
<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 }}
>
{option.value.charAt(0).toUpperCase() +
option.value.slice(1)}
</Typography>
<Typography
level='body-sm'
sx={{ mt: 0, mb: 0, lineHeight: 1.1 }}
>
{option.description}
</Typography>
</Box>
</Option>
))}
</Select>
)}
{isAdmin &&
member.userId !== userProfile.id &&
member.isActive && (
<Button
variant='outlined'
color='danger'
size='sm'
onClick={() => {
showConfirmation(
`Are you sure you want to remove ${member.displayName} from your circle?`,
'Remove Member',
() => {
DeleteCircleMember(
member.circleId,
member.userId,
).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Removed member successfully',
})
queryClient.invalidateQueries(['circleMembers'])
queryClient.invalidateQueries(['userCircle'])
queryClient.refetchQueries(['circleMembers'])
queryClient.refetchQueries(['userCircle'])
setCircleMembers(prevMembers =>
prevMembers.filter(
m => m.userId !== member.userId,
),
)
}
})
},
'Remove',
'Cancel',
'danger',
)
}}
>
<Delete />
</Button>
)}
</Box>
</Box>
</Card>
))}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='title-md'>Circle Member Requests</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{lastRefresh && (
<Typography level='body-sm' color='neutral'>
Last updated: {moment(lastRefresh).format('MMM DD, HH:mm')}
</Typography>
)}
<Button
size='sm'
variant='soft'
onClick={refreshMemberRequests}
disabled={isRefreshing}
startDecorator={
isRefreshing ? <CircularProgress size='sm' /> : <Refresh />
}
>
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</Box>
</Box>
{circleMemberRequests.map(request => (
<Card key={request.id} className='p-4'>
<Typography level='body-md'>
{request.displayName} wants to join your circle.
</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',
() => {
AcceptCircleMemberRequest(request.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Accepted request successfully',
})
queryClient.invalidateQueries(['circleMembers'])
queryClient.invalidateQueries(['circleMemberRequests'])
queryClient.invalidateQueries(['userCircle'])
queryClient.refetchQueries(['circleMembers'])
queryClient.refetchQueries(['circleMemberRequests'])
queryClient.refetchQueries(['userCircle'])
refreshMemberRequests()
GetAllCircleMembers().then(data => {
setCircleMembers(data.res ? data.res : [])
})
}
})
},
'Accept',
'Cancel',
)
}}
>
Accept
</Button>
</Card>
))}
<Divider> 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.
</Typography>
<Typography level='title-sm' mb={-1}>
Enter Circle code:
<Input
placeholder='Enter code'
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:
'Joined circle successfully, wait for the circle owner to accept your request.',
})
setTimeout(() => navigate('/'), 3000)
} else {
if (resp.status === 409) {
showNotification({
type: 'error',
message: 'You are already a member of this circle',
})
} else {
showNotification({
type: 'error',
message: 'Failed to join circle',
})
}
setTimeout(() => navigate('/'), 3000)
}
})
}}
>
Join Circle
</Button>
</Typography>
</div>
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</SettingsLayout>
)
}
export default CircleSettings

View File

@@ -4,8 +4,6 @@ import {
Box,
Button,
Card,
CircularProgress,
Divider,
Input,
Modal,
ModalClose,
@@ -22,6 +20,8 @@ import {
RegenerateBackupCodes,
SetupMFA,
} from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
import SettingsLayout from './SettingsLayout'
const MFASettings = () => {
const [mfaEnabled, setMfaEnabled] = useState(false)
@@ -202,69 +202,68 @@ const MFASettings = () => {
}
if (loading) {
return (
<Box className='flex justify-center py-4'>
<CircularProgress />
</Box>
)
return <LoadingComponent />
}
return (
<div className='grid gap-4 py-4' id='mfa'>
<Typography level='h3'>Multi-Factor Authentication</Typography>
<Divider />
<Typography level='body-md'>
Add an extra layer of security to your account with multi-factor
authentication (MFA). When enabled, you&apos;ll need to provide a
verification code from your authenticator app in addition to your
password when signing in.
</Typography>
<SettingsLayout title='Multi-Factor Authentication'>
<div className='grid gap-4 py-4' id='mfa'>
<Typography level='body-md'>
Add an extra layer of security to your account with multi-factor
authentication (MFA). When enabled, you&apos;ll need to provide a
verification code from your authenticator app in addition to your
password when signing in.
</Typography>
{success && (
<Alert color='success' onClose={() => setSuccess('')}>
{success}
</Alert>
)}
{success && (
<Alert color='success' onClose={() => setSuccess('')}>
{success}
</Alert>
)}
{error && (
<Alert color='danger' onClose={() => setError('')}>
{error}
</Alert>
)}
{error && (
<Alert color='danger' onClose={() => setError('')}>
{error}
</Alert>
)}
<Card variant='outlined'>
<Box className='flex items-center justify-between'>
<Box className='flex items-center gap-3'>
<Security color='primary' />
<Box>
<Typography level='title-md'>
Two-Factor Authentication
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{mfaEnabled
? 'Your account is protected with 2FA'
: 'Secure your account with an authenticator app'}
</Typography>
<Card variant='outlined'>
<Box className='flex items-center justify-between'>
<Box className='flex items-center gap-3'>
<Security color='primary' />
<Box>
<Typography level='title-md'>
Two-Factor Authentication
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{mfaEnabled
? 'Your account is protected with 2FA'
: 'Secure your account with an authenticator app'}
</Typography>
</Box>
</Box>
<Box className='flex items-center gap-2'>
{mfaEnabled ? (
<Button
color='danger'
variant='outlined'
onClick={() => setDisableModalOpen(true)}
>
Disable
</Button>
) : (
<Button
color='primary'
variant='solid'
onClick={handleSetupMFA}
>
Enable
</Button>
)}
</Box>
</Box>
<Box className='flex items-center gap-2'>
{mfaEnabled ? (
<Button
color='danger'
variant='outlined'
onClick={() => setDisableModalOpen(true)}
>
Disable
</Button>
) : (
<Button color='primary' variant='solid' onClick={handleSetupMFA}>
Enable
</Button>
)}
</Box>
</Box>
</Card>
{/*
</Card>
{/*
{mfaEnabled && (
<Card variant='outlined'>
<Box className='flex items-center justify-between'>
@@ -290,87 +289,194 @@ const MFASettings = () => {
</Card>
)} */}
{/* Setup MFA Modal */}
<Modal open={setupModalOpen} onClose={closeSetupModal}>
<ModalDialog size='md' sx={{ maxWidth: 500 }}>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Set up Multi-Factor Authentication
</Typography>
{/* Setup MFA Modal */}
<Modal open={setupModalOpen} onClose={closeSetupModal}>
<ModalDialog size='md' sx={{ maxWidth: 500 }}>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Set up Multi-Factor Authentication
</Typography>
{setupStep === 1 && setupData && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 1:</strong> Scan the QR code below with your
authenticator app (Google Authenticator, Authy, etc.)
</Typography>
<Box className='flex justify-center rounded bg-white p-4'>
{qrCodeDataUrl || setupData.qrCode ? (
<img
src={
qrCodeDataUrl ||
`data:image/png;base64,${setupData.qrCode}`
}
alt='MFA QR Code'
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
) : (
<Alert color='danger'>
QR code could not be generated. Please try again or use the
manual entry key below.
</Alert>
)}
</Box>
<Alert
color='neutral'
variant='soft'
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
<strong>Manual entry key:</strong>
{setupStep === 1 && setupData && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 1:</strong> Scan the QR code below with your
authenticator app (Google Authenticator, Authy, etc.)
</Typography>
<Typography
level='body-sm'
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
<Box className='flex justify-center rounded bg-white p-4'>
{qrCodeDataUrl || setupData.qrCode ? (
<img
src={
qrCodeDataUrl ||
`data:image/png;base64,${setupData.qrCode}`
}
alt='MFA QR Code'
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
) : (
<Alert color='danger'>
QR code could not be generated. Please try again or use
the manual entry key below.
</Alert>
)}
</Box>
<Alert
color='neutral'
variant='soft'
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
{setupData.secret}
<Typography level='title-sm'>
<strong>Manual entry key:</strong>
</Typography>
<Typography
level='body-sm'
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
>
{setupData.secret}
</Typography>
</Alert>
<Button
color='primary'
onClick={() => setSetupStep(2)}
startDecorator={<Smartphone />}
>
I've added the account to my app
</Button>
</Stack>
)}
{setupStep === 2 && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 2:</strong> Enter the 6-digit verification code
from your authenticator app
</Typography>
<Input
placeholder='Enter 6-digit code'
value={verificationCode}
size='lg'
// send on enter:
onKeyDown={e => {
if (e.key === 'Enter' && verificationCode.length === 6) {
handleConfirmMFA()
}
}}
onChange={e => setVerificationCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
}}
slotProps={{
input: {
maxLength: 6,
pattern: '[0-9]*',
},
}}
/>
{error && <Alert color='danger'>{error}</Alert>}
<Box className='flex gap-2'>
<Button
variant='outlined'
onClick={() => setSetupStep(1)}
sx={{ flex: 1 }}
>
Back
</Button>
<Button
color='primary'
onClick={handleConfirmMFA}
disabled={verificationCode.length !== 6}
sx={{ flex: 1 }}
>
Verify & Enable
</Button>
</Box>
</Stack>
)}
{setupStep === 3 && (
<Stack spacing={3}>
<Box className='text-center'>
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
<Typography level='h4' color='success'>
MFA Successfully Enabled!
</Typography>
</Box>
<Alert color='warning'>
<Typography level='title-sm' sx={{ mb: 1 }}>
Save these backup codes in a safe place
</Typography>
<Typography level='body-sm'>
You can use these codes to access your account if you lose
your authenticator device. Each code can only be used once.
</Typography>
</Alert>
<Card variant='outlined' sx={{ p: 2 }}>
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
{backupCodes?.map((code, index) => (
<Typography
key={index}
level='body-sm'
sx={{ fontFamily: 'monospace' }}
>
{code}
</Typography>
))}
</Box>
</Card>
<Button color='primary' onClick={closeSetupModal}>
I've saved my backup codes
</Button>
</Stack>
)}
</ModalDialog>
</Modal>
{/* Disable MFA Modal */}
<Modal open={disableModalOpen} onClose={closeDisableModal}>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Disable Multi-Factor Authentication
</Typography>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Disabling MFA will make your account less secure. Are you sure
you want to continue?
</Typography>
</Alert>
<Button
color='primary'
onClick={() => setSetupStep(2)}
startDecorator={<Smartphone />}
>
I've added the account to my app
</Button>
</Stack>
)}
{setupStep === 2 && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 2:</strong> Enter the 6-digit verification code
from your authenticator app
Enter a verification code from your authenticator app to
confirm:
</Typography>
<Input
placeholder='Enter 6-digit code'
value={verificationCode}
value={disableCode}
size='lg'
// send on enter:
onKeyDown={e => {
if (e.key === 'Enter' && verificationCode.length === 6) {
handleConfirmMFA()
if (e.key === 'Enter' && disableCode.length === 6) {
handleDisableMFA()
}
}}
onChange={e => setVerificationCode(e.target.value)}
onChange={e => setDisableCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
@@ -389,39 +495,40 @@ const MFASettings = () => {
<Box className='flex gap-2'>
<Button
variant='outlined'
onClick={() => setSetupStep(1)}
onClick={closeDisableModal}
sx={{ flex: 1 }}
>
Back
Cancel
</Button>
<Button
color='primary'
onClick={handleConfirmMFA}
disabled={verificationCode.length !== 6}
color='danger'
onClick={handleDisableMFA}
disabled={disableCode.length !== 6}
sx={{ flex: 1 }}
>
Verify & Enable
Disable MFA
</Button>
</Box>
</Stack>
)}
</ModalDialog>
</Modal>
{/* Backup Codes Modal */}
<Modal
open={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
New Backup Codes
</Typography>
{setupStep === 3 && (
<Stack spacing={3}>
<Box className='text-center'>
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
<Typography level='h4' color='success'>
MFA Successfully Enabled!
</Typography>
</Box>
<Alert color='warning'>
<Typography level='title-sm' sx={{ mb: 1 }}>
Save these backup codes in a safe place
</Typography>
<Typography level='body-sm'>
You can use these codes to access your account if you lose
your authenticator device. Each code can only be used once.
Your previous backup codes are now invalid. Save these new
codes in a safe place. Each code can only be used once.
</Typography>
</Alert>
@@ -439,123 +546,17 @@ const MFASettings = () => {
</Box>
</Card>
<Button color='primary' onClick={closeSetupModal}>
<Button
color='primary'
onClick={() => setBackupCodesModalOpen(false)}
>
I've saved my backup codes
</Button>
</Stack>
)}
</ModalDialog>
</Modal>
{/* Disable MFA Modal */}
<Modal open={disableModalOpen} onClose={closeDisableModal}>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Disable Multi-Factor Authentication
</Typography>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Disabling MFA will make your account less secure. Are you sure
you want to continue?
</Typography>
</Alert>
<Typography level='body-md'>
Enter a verification code from your authenticator app to confirm:
</Typography>
<Input
placeholder='Enter 6-digit code'
value={disableCode}
size='lg'
onKeyDown={e => {
if (e.key === 'Enter' && disableCode.length === 6) {
handleDisableMFA()
}
}}
onChange={e => setDisableCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
}}
slotProps={{
input: {
maxLength: 6,
pattern: '[0-9]*',
},
}}
/>
{error && <Alert color='danger'>{error}</Alert>}
<Box className='flex gap-2'>
<Button
variant='outlined'
onClick={closeDisableModal}
sx={{ flex: 1 }}
>
Cancel
</Button>
<Button
color='danger'
onClick={handleDisableMFA}
disabled={disableCode.length !== 6}
sx={{ flex: 1 }}
>
Disable MFA
</Button>
</Box>
</Stack>
</ModalDialog>
</Modal>
{/* Backup Codes Modal */}
<Modal
open={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
New Backup Codes
</Typography>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Your previous backup codes are now invalid. Save these new codes
in a safe place. Each code can only be used once.
</Typography>
</Alert>
<Card variant='outlined' sx={{ p: 2 }}>
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
{backupCodes?.map((code, index) => (
<Typography
key={index}
level='body-sm'
sx={{ fontFamily: 'monospace' }}
>
{code}
</Typography>
))}
</Box>
</Card>
<Button
color='primary'
onClick={() => setBackupCodesModalOpen(false)}
>
I've saved my backup codes
</Button>
</Stack>
</ModalDialog>
</Modal>
</div>
</ModalDialog>
</Modal>
</div>
</SettingsLayout>
)
}

View File

@@ -0,0 +1,12 @@
import NotificationSetting from './NotificationSetting'
import SettingsLayout from './SettingsLayout'
const NotificationSettings = () => {
return (
<SettingsLayout title="Notification Settings">
<NotificationSetting />
</SettingsLayout>
)
}
export default NotificationSettings

View File

@@ -4,7 +4,6 @@ import {
Box,
Button,
Card,
Divider,
Input,
Typography,
} from '@mui/joy'
@@ -19,6 +18,7 @@ import { UpdateUserDetails } from '../../utils/Fetcher'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getCroppedImg } from '../../utils/imageCropUtils'
import { UploadFile } from '../../utils/TokenManager'
import SettingsLayout from './SettingsLayout'
const ProfileSettings = () => {
const { data: userProfile } = useUserProfile()
@@ -136,165 +136,165 @@ const ProfileSettings = () => {
// Helper to resolve photoURL with baseURL if needed
return (
<div className='grid gap-4 py-4' id='profile'>
<Typography level='h3'>Profile Settings</Typography>
<Divider />
<Typography level='body-md'>
Update your display name and profile photo.
</Typography>
<Card
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
p: 2,
maxWidth: 400,
}}
>
<Avatar src={photoURL} sx={{ width: 64, height: 64 }} />
<Box sx={{ flex: 1 }}>
<SettingsLayout title='Profile Settings'>
<div className='grid gap-4 py-4' id='profile'>
<Typography level='body-md'>
Update your display name and profile photo.
</Typography>
<Card
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
p: 2,
maxWidth: 400,
}}
>
<Avatar src={photoURL} sx={{ width: 64, height: 64 }} />
<Box sx={{ flex: 1 }}>
<Button
variant='soft'
color='primary'
onClick={() => fileInputRef.current.click()}
loading={isUploading}
sx={{ mb: 1 }}
>
Change Photo
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handlePhotoChange}
/>
</Box>
</Card>
<Modal
open={showCropper}
onClose={() => {
setShowCropper(false)
setSelectedFile(null)
}}
>
<ModalDialog
layout='center'
sx={{
width: 360,
maxWidth: '90vw',
bgcolor: '#fff',
borderRadius: 2,
boxShadow: 24,
p: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 420,
}}
>
<Box sx={{ width: 320, height: 320, position: 'relative', mt: 2 }}>
<Cropper
image={selectedFile}
crop={crop}
zoom={zoom}
aspect={1}
cropShape='round'
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
width: '100%',
p: 2,
mt: 2,
}}
>
<Button
onClick={handleCropSave}
loading={isUploading}
variant='solid'
color='primary'
size='md'
sx={{ mr: 1 }}
>
Save
</Button>
<Button
onClick={() => {
setShowCropper(false)
setSelectedFile(null)
}}
variant='soft'
color='neutral'
>
Cancel
</Button>
</Box>
</ModalDialog>
</Modal>
<Box sx={{ maxWidth: 400, mt: 3 }}>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
Display Name
</Typography>
<Input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder='Enter your display name'
sx={{ mb: 2 }}
/>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
Timezone
</Typography>
<Autocomplete
value={timezone}
onChange={(e, newValue) => setTimezone(newValue)}
options={timezones}
getOptionLabel={tz => {
const formattedTimezone = tz.replace(/_/g, ' ')
const currentTime = new Date().toLocaleString('en-US', {
timeZone: tz,
timeStyle: 'short',
})
return `${formattedTimezone} (${currentTime})`
}}
filterOptions={(options, { inputValue }) => {
if (!inputValue) return options
const searchTerms = inputValue.toLowerCase().split(/\s+/)
return options.filter(tz => {
const timezoneLower = tz.toLowerCase()
const timezoneParts = tz.toLowerCase().split(/[/_]/)
return searchTerms.every(
term =>
timezoneLower.includes(term) ||
timezoneParts.some(part => part.includes(term)),
)
})
}}
placeholder='Select your timezone'
sx={{ mb: 2 }}
/>
<Button
variant='soft'
color='primary'
onClick={() => fileInputRef.current.click()}
loading={isUploading}
sx={{ mb: 1 }}
onClick={handleSave}
loading={isSaving}
sx={{ width: 120 }}
>
Change Photo
Save
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handlePhotoChange}
/>
</Box>
</Card>
<Modal
open={showCropper}
onClose={() => {
setShowCropper(false)
setSelectedFile(null)
}}
>
<ModalDialog
layout='center'
sx={{
width: 360,
maxWidth: '90vw',
bgcolor: '#fff',
borderRadius: 2,
boxShadow: 24,
p: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 420,
}}
>
<Box sx={{ width: 320, height: 320, position: 'relative', mt: 2 }}>
<Cropper
image={selectedFile}
crop={crop}
zoom={zoom}
aspect={1}
cropShape='round'
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
width: '100%',
p: 2,
mt: 2,
}}
>
<Button
onClick={handleCropSave}
loading={isUploading}
variant='solid'
color='primary'
size='md'
sx={{ mr: 1 }}
>
Save
</Button>
<Button
onClick={() => {
setShowCropper(false)
setSelectedFile(null)
}}
variant='soft'
color='neutral'
>
Cancel
</Button>
</Box>
</ModalDialog>
</Modal>
<Box sx={{ maxWidth: 400, mt: 3 }}>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
Display Name
</Typography>
<Input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder='Enter your display name'
sx={{ mb: 2 }}
/>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
Timezone
</Typography>
<Autocomplete
value={timezone}
onChange={(e, newValue) => setTimezone(newValue)}
options={timezones}
getOptionLabel={tz => {
const formattedTimezone = tz.replace(/_/g, ' ')
const currentTime = new Date().toLocaleString('en-US', {
timeZone: tz,
timeStyle: 'short',
})
return `${formattedTimezone} (${currentTime})`
}}
filterOptions={(options, { inputValue }) => {
if (!inputValue) return options
const searchTerms = inputValue.toLowerCase().split(/\s+/)
return options.filter(tz => {
const timezoneLower = tz.toLowerCase()
const timezoneParts = tz.toLowerCase().split(/[/_]/)
return searchTerms.every(
term =>
timezoneLower.includes(term) ||
timezoneParts.some(part => part.includes(term)),
)
})
}}
placeholder='Select your timezone'
sx={{ mb: 2 }}
/>
<Button
variant='soft'
color='primary'
onClick={handleSave}
loading={isSaving}
sx={{ width: 120 }}
>
Save
</Button>
</Box>
</div>
</div>
</SettingsLayout>
)
}

View File

@@ -1,5 +1,5 @@
import { Capacitor } from '@capacitor/core'
import { Refresh } from '@mui/icons-material'
import { Delete, Refresh } from '@mui/icons-material'
import {
Box,
Button,
@@ -424,14 +424,10 @@ const Settings = () => {
))}
</Select>
)}
{userProfile.role === 'admin' &&
{isAdmin &&
member.userId !== userProfile.id &&
member.isActive && (
<Button
disabled={
circleMembers.find(m => userProfile.id == m.userId)
.role !== 'admin'
}
variant='outlined'
color='danger'
size='sm'
@@ -449,6 +445,17 @@ const Settings = () => {
type: 'success',
message: 'Removed member successfully',
})
// 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,
),
)
}
})
},
@@ -458,7 +465,7 @@ const Settings = () => {
)
}}
>
Remove
<Delete />
</Button>
)}
</Box>
@@ -514,8 +521,18 @@ const Settings = () => {
type: 'success',
message: 'Accepted request successfully',
})
// reload the page
window.location.reload()
// 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 : [])
})
}
})
},
@@ -550,23 +567,29 @@ const Settings = () => {
<Button
variant='soft'
onClick={() => {
showConfirmation(
`Are you sure you want to leave your circle and join '${circleInviteCode}'?`,
'Join Circle',
() => {
JoinCircle(circleInviteCode).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message:
'Joined circle successfully, wait for the circle owner to accept your request.',
})
}
JoinCircle(circleInviteCode).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message:
'Joined circle successfully, wait for the circle owner to accept your request.',
})
},
'Join',
'Cancel',
)
setTimeout(() => navigate('/'), 3000)
} else {
if (resp.status === 409) {
showNotification({
type: 'error',
message: 'You are already a member of this circle',
})
} else {
showNotification({
type: 'error',
message: 'Failed to join circle',
})
}
setTimeout(() => navigate('/'), 3000)
}
})
}}
>
Join Circle

View File

@@ -0,0 +1,21 @@
import { Box, Container, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom'
const SettingsLayout = ({ title, children }) => {
const navigate = useNavigate()
return (
<Container>
<Box sx={{ py: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography level='h2' sx={{ flexGrow: 1 }}>
{title}
</Typography>
</Box>
{children}
</Box>
</Container>
)
}
export default SettingsLayout

View File

@@ -1,11 +1,13 @@
import {
AccountCircle,
Api,
ChevronRight,
Circle,
Notifications,
Palette,
Person,
Security,
Settings,
Star,
Storage,
ViewSidebar,
@@ -17,7 +19,11 @@ import {
Card,
CardContent,
Container,
Grid,
List,
ListItem,
ListItemButton,
ListItemContent,
ListItemDecorator,
Stack,
Typography,
} from '@mui/joy'
@@ -36,15 +42,13 @@ const SettingsOverview = () => {
description:
'Update your profile information, photo, display name, and timezone preferences.',
icon: <Person />,
color: 'primary',
},
{
id: 'circle',
title: 'Circle Settings',
description:
'Manage your circle, invite members, handle join requests, and configure webhooks.',
'Manage your circle, invite members, and handle join requests.',
icon: <Circle />,
color: 'success',
},
{
id: 'account',
@@ -52,7 +56,6 @@ const SettingsOverview = () => {
description:
'Manage your subscription, change password, and account deletion options.',
icon: <AccountCircle />,
color: 'warning',
},
{
id: 'notifications',
@@ -60,7 +63,6 @@ const SettingsOverview = () => {
description:
'Configure push notifications, email alerts, and notification targets for tasks.',
icon: <Notifications />,
color: 'info',
},
{
id: 'mfa',
@@ -68,7 +70,6 @@ const SettingsOverview = () => {
description:
'Add an extra layer of security with MFA using authenticator apps.',
icon: <Security />,
color: 'danger',
},
{
id: 'apitokens',
@@ -76,7 +77,6 @@ const SettingsOverview = () => {
description:
'Generate and manage access tokens for third-party integrations and API access.',
icon: <Api />,
color: 'neutral',
},
{
id: 'storage',
@@ -84,7 +84,6 @@ const SettingsOverview = () => {
description:
'Backup and restore your data, manage local storage and sync preferences.',
icon: <Storage />,
color: 'primary',
},
{
id: 'sidepanel',
@@ -92,7 +91,6 @@ const SettingsOverview = () => {
description:
'Customize the layout and visibility of cards in the sidepanel interface.',
icon: <ViewSidebar />,
color: 'success',
},
{
id: 'theme',
@@ -100,12 +98,18 @@ const SettingsOverview = () => {
description:
'Choose your preferred theme and configure dark/light mode settings.',
icon: <Palette />,
color: 'warning',
},
{
id: 'advanced',
title: 'Advanced Settings',
description:
'Configure webhooks, real-time updates, and other advanced features for enhanced productivity.',
icon: <Settings />,
},
]
const handleCardClick = settingId => {
navigate(`/settings/detailed#${settingId}`)
navigate(`/settings/${settingId}`)
}
return (
@@ -127,179 +131,178 @@ const SettingsOverview = () => {
{/* Upgrade Card - Only show if user is not a Plus member */}
{userProfile && !isPlusAccount(userProfile) && (
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid xs={12}>
<Card
variant='outlined'
sx={{
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
background: 'linear-gradient(135deg, #0891b2 0%, #0e7490 100%)',
border: 'none',
color: 'white',
'&:hover': {
boxShadow: 'xl',
transform: 'translateY(-3px)',
},
}}
onClick={() => navigate('/settings/detailed#account')}
>
<CardContent sx={{ p: { xs: 1.5, md: 3 } }}>
<Box sx={{ mb: 3 }}>
<Card
variant='outlined'
sx={{
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
background: 'linear-gradient(135deg, #0891b2 0%, #0e7490 100%)',
border: 'none',
color: 'white',
'&:hover': {
boxShadow: 'xl',
transform: 'translateY(-3px)',
},
}}
onClick={() => navigate('/settings/account')}
>
<CardContent sx={{ p: { xs: 1.5, md: 3 } }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: { xs: 'column', md: 'row' },
gap: { xs: 1.5, md: 2 },
textAlign: { xs: 'center', md: 'left' },
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: { xs: 'column', md: 'row' },
gap: { xs: 1.5, md: 2 },
textAlign: { xs: 'center', md: 'left' },
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: { xs: 'column', md: 'row' },
gap: { xs: 1, md: 3 },
width: { xs: '100%', md: 'auto' },
}}
>
<Avatar
sx={{
'--Avatar-size': { xs: '36px', md: '60px' },
background: 'rgba(255, 255, 255, 0.2)',
backdropFilter: 'blur(10px)',
}}
>
<Star
sx={{ fontSize: { xs: 18, md: 30 }, color: 'white' }}
/>
</Avatar>
<Box>
<Typography
level='title-lg'
sx={{
color: 'white',
mb: { xs: 0.25, md: 0.5 },
fontWeight: 'bold',
fontSize: { xs: '0.95rem', md: '1.25rem' },
}}
>
Upgrade to Plus
</Typography>
<Typography
level='body-md'
sx={{
color: 'rgba(255, 255, 255, 0.9)',
mb: { xs: 0.5, md: 1 },
fontSize: { xs: '0.75rem', md: '1rem' },
lineHeight: { xs: 1.3, md: 1.5 },
}}
>
Unlock powerful features to enhance your productivity
</Typography>
<Box
sx={{
display: { xs: 'none', sm: 'flex' },
flexWrap: 'wrap',
gap: 1,
fontSize: '0.875rem',
color: 'rgba(255, 255, 255, 0.8)',
}}
>
<span> Rich text descriptions</span>
<span> Task notifications</span>
<span> API integrations</span>
<span> Advanced automation</span>
</Box>
</Box>
</Box>
<Button
variant='solid'
size='sm'
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
border: '1px solid rgba(255, 255, 255, 0.3)',
backdropFilter: 'blur(10px)',
px: { xs: 1.5, md: 3 },
py: { xs: 0.5, md: 1.5 },
fontWeight: 'bold',
minWidth: { xs: '80px', md: '120px' },
width: { xs: '100%', md: 'auto' },
fontSize: { xs: '0.75rem', md: '0.875rem' },
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.3)',
transform: 'scale(1.05)',
},
}}
onClick={e => {
e.stopPropagation()
navigate('/settings/detailed#account')
}}
>
Upgrade Now
</Button>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
)}
<Grid container spacing={2}>
{settingsCards.map(setting => (
<Grid key={setting.id} xs={4} sm={4} md={4}>
<Card
variant='outlined'
sx={{
height: '100%',
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: 'md',
transform: 'translateY(-2px)',
borderColor: `${setting.color}.500`,
},
}}
onClick={() => handleCardClick(setting.id)}
>
<CardContent sx={{ p: 2 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
mb: 1.5,
gap: { xs: 1, md: 3 },
width: { xs: '100%', md: 'auto' },
}}
>
<Avatar
sx={{
'--Avatar-size': { xs: '36px', md: '60px' },
background: 'rgba(255, 255, 255, 0.2)',
backdropFilter: 'blur(10px)',
}}
>
<Star
sx={{ fontSize: { xs: 18, md: 30 }, color: 'white' }}
/>
</Avatar>
<Box>
<Typography
level='title-lg'
sx={{
color: 'white',
mb: { xs: 0.25, md: 0.5 },
fontWeight: 'bold',
fontSize: { xs: '0.95rem', md: '1.25rem' },
}}
>
Upgrade to Plus
</Typography>
<Typography
level='body-md'
sx={{
color: 'rgba(255, 255, 255, 0.9)',
mb: { xs: 0.5, md: 1 },
fontSize: { xs: '0.75rem', md: '1rem' },
lineHeight: { xs: 1.3, md: 1.5 },
}}
>
Unlock powerful features to enhance your productivity
</Typography>
<Box
sx={{
display: { xs: 'none', sm: 'flex' },
flexWrap: 'wrap',
gap: 1,
fontSize: '0.875rem',
color: 'rgba(255, 255, 255, 0.8)',
}}
>
<span> Rich text descriptions</span>
<span> Task notifications</span>
<span> API integrations</span>
<span> Advanced automation</span>
</Box>
</Box>
</Box>
<Button
variant='solid'
size='sm'
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
border: '1px solid rgba(255, 255, 255, 0.3)',
backdropFilter: 'blur(10px)',
px: { xs: 1.5, md: 3 },
py: { xs: 0.5, md: 1.5 },
fontWeight: 'bold',
minWidth: { xs: '80px', md: '120px' },
width: { xs: '100%', md: 'auto' },
fontSize: { xs: '0.75rem', md: '0.875rem' },
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.3)',
transform: 'scale(1.05)',
},
}}
onClick={e => {
e.stopPropagation()
navigate('/settings/account')
}}
>
Upgrade Now
</Button>
</Box>
</CardContent>
</Card>
</Box>
)}
<Box sx={{ mx: 'auto' }}>
<List
sx={{
'--List-gap': '0px',
'--ListItem-paddingY': '16px',
'--ListItem-paddingX': '20px',
}}
>
{settingsCards.map((setting, index) => (
<ListItem key={setting.id} sx={{ p: 0 }}>
<ListItemButton
onClick={() => handleCardClick(setting.id)}
sx={{
'&:hover': {
backgroundColor: 'background.level1',
},
py: 2.5,
px: 3,
borderRadius: 'lg',
mb: 1,
}}
>
<ListItemDecorator>
<Avatar
variant='soft'
color={setting.color}
sx={{ mb: 1, '--Avatar-size': '40px' }}
color='neutral'
sx={{ '--Avatar-size': '48px' }}
>
{setting.icon}
</Avatar>
<Typography level='title-sm' component='h3' sx={{ mb: 1 }}>
</ListItemDecorator>
<ListItemContent sx={{ ml: 2 }}>
<Typography
level='title-md'
sx={{ mb: 0.5, fontWeight: 'lg' }}
>
{setting.title}
</Typography>
<Typography
level='body-xs'
level='body-sm'
color='neutral'
sx={{
lineHeight: 1.4,
display: { xs: 'none', sm: 'block' },
}}
sx={{ lineHeight: 1.4 }}
>
{setting.description}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</ListItemContent>
<ChevronRight
sx={{ color: 'text.tertiary', fontSize: '20px' }}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Container>
)
}

View File

@@ -0,0 +1,7 @@
import { Outlet } from 'react-router-dom'
const SettingsRoutes = () => {
return <Outlet />
}
export default SettingsRoutes

View File

@@ -23,6 +23,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import SettingsLayout from './SettingsLayout'
const DEFAULT_SIDEPANEL_CONFIG = [
{
@@ -122,136 +123,148 @@ const SidepanelSettings = () => {
}
return (
<Box>
<Typography level='h4' sx={{ mb: 2 }}>
Sidepanel Settings
</Typography>
<Typography level='body-md' sx={{ mb: 3 }}>
Customize which cards appear in the sidepanel and their order. Drag and
drop to reorder, or toggle visibility for each card.
</Typography>
<SettingsLayout title='Sidepanel Customization'>
<div className='grid gap-4'>
<Box>
<Typography level='h4' sx={{ mb: 2 }}>
Sidepanel Settings
</Typography>
<Typography level='body-md' sx={{ mb: 3 }}>
Customize which cards appear in the sidepanel and their order. Drag
and drop to reorder, or toggle visibility for each card.
</Typography>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId='sidepanel-cards'>
{provided => (
<List
{...provided.droppableProps}
ref={provided.innerRef}
sx={{ gap: 1 }}
>
{config.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<ListItem
ref={provided.innerRef}
{...provided.draggableProps}
sx={{
p: 0,
backgroundColor: snapshot.isDragging
? 'var(--joy-palette-neutral-softBg)'
: 'transparent',
borderRadius: 'var(--joy-radius-md)',
}}
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId='sidepanel-cards'>
{provided => (
<List
{...provided.droppableProps}
ref={provided.innerRef}
sx={{ gap: 1 }}
>
{config.map((item, index) => (
<Draggable
key={item.id}
draggableId={item.id}
index={index}
>
<Card
sx={{
width: '100%',
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
gap: 2,
p: 2,
opacity: item.enabled ? 1 : 0.6,
border: snapshot.isDragging
? '2px solid var(--joy-palette-primary-400)'
: '1px solid var(--joy-palette-divider)',
}}
>
<ListItemDecorator>
<IconButton
{...provided.dragHandleProps}
variant='plain'
size='sm'
sx={{
cursor: 'grab',
'&:active': { cursor: 'grabbing' },
}}
>
<DragIndicator />
</IconButton>
</ListItemDecorator>
<IconButton
sx={{ color: 'var(--joy-palette-primary-500)' }}
{(provided, snapshot) => (
<ListItem
ref={provided.innerRef}
{...provided.draggableProps}
sx={{
p: 0,
backgroundColor: snapshot.isDragging
? 'var(--joy-palette-neutral-softBg)'
: 'transparent',
borderRadius: 'var(--joy-radius-md)',
}}
>
{getIcon(item.iconName)}
</IconButton>
<ListItemContent sx={{ flex: 1 }}>
<Box
<Card
sx={{
width: '100%',
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
gap: 2,
p: 2,
opacity: item.enabled ? 1 : 0.6,
border: snapshot.isDragging
? '2px solid var(--joy-palette-primary-400)'
: '1px solid var(--joy-palette-divider)',
}}
>
<Typography
level='title-sm'
sx={{ fontWeight: 600 }}
>
{item.name}
</Typography>
<Typography
level='body-xs'
sx={{
color: 'var(--joy-palette-text-tertiary)',
}}
>
- {item.description}
</Typography>
</Box>
</ListItemContent>
<ListItemDecorator>
<IconButton
{...provided.dragHandleProps}
variant='plain'
size='sm'
sx={{
cursor: 'grab',
'&:active': { cursor: 'grabbing' },
}}
>
<DragIndicator />
</IconButton>
</ListItemDecorator>
<FormControl>
<Checkbox
checked={item.enabled}
onChange={e =>
handleToggleEnabled(item.id, e.target.checked)
}
overlay
variant='plain'
size='lg'
checkedIcon={<Visibility />}
uncheckedIcon={<VisibilityOff />}
/>
</FormControl>
</Card>
</ListItem>
)}
</Draggable>
))}
{provided.placeholder}
</List>
)}
</Droppable>
</DragDropContext>
<IconButton
sx={{ color: 'var(--joy-palette-primary-500)' }}
>
{getIcon(item.iconName)}
</IconButton>
<Box
sx={{ mt: 3, pt: 2, borderTop: '1px solid var(--joy-palette-divider)' }}
>
<Button
variant='outlined'
color='neutral'
onClick={resetToDefaults}
size='sm'
>
Reset to Defaults
</Button>
<FormHelperText sx={{ mt: 1 }}>
This will restore all cards to their default visibility and order.
</FormHelperText>
</Box>
</Box>
<ListItemContent sx={{ flex: 1 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
<Typography
level='title-sm'
sx={{ fontWeight: 600 }}
>
{item.name}
</Typography>
<Typography
level='body-xs'
sx={{
color: 'var(--joy-palette-text-tertiary)',
}}
>
- {item.description}
</Typography>
</Box>
</ListItemContent>
<FormControl>
<Checkbox
checked={item.enabled}
onChange={e =>
handleToggleEnabled(item.id, e.target.checked)
}
overlay
variant='plain'
size='lg'
checkedIcon={<Visibility />}
uncheckedIcon={<VisibilityOff />}
/>
</FormControl>
</Card>
</ListItem>
)}
</Draggable>
))}
{provided.placeholder}
</List>
)}
</Droppable>
</DragDropContext>
<Box
sx={{
mt: 3,
pt: 2,
borderTop: '1px solid var(--joy-palette-divider)',
}}
>
<Button
variant='outlined'
color='neutral'
onClick={resetToDefaults}
size='sm'
>
Reset to Defaults
</Button>
<FormHelperText sx={{ mt: 1 }}>
This will restore all cards to their default visibility and order.
</FormHelperText>
</Box>
</Box>
</div>
</SettingsLayout>
)
}

View File

@@ -1,18 +1,12 @@
import { Capacitor } from '@capacitor/core'
import {
Button,
Card,
Chip,
Divider,
LinearProgress,
Typography,
} from '@mui/joy'
import { Button, Card, Chip, LinearProgress, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { GetStorageUsage } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import SettingsLayout from './SettingsLayout'
const StorageSettings = () => {
const Navigate = useNavigate()
@@ -62,114 +56,114 @@ const StorageSettings = () => {
const totalMB = (usage.total / (1024 * 1024)).toFixed(2)
return (
<div className='grid gap-4 py-4' id='storage'>
<Typography level='h3'>Storage Settings</Typography>
<Divider />
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
Server Storage Usage
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
<SettingsLayout title='Storage Settings'>
<div className='grid gap-4 py-4' id='storage'>
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
Server Storage Usage
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
This is the storage used by your account on our servers (e.g. files,
images, and data you have uploaded).
</Typography>
{!isPlusAccount(userProfile) ? (
<>
<LinearProgress
determinate
value={0}
sx={{
mb: 1,
opacity: 0.4,
'& .MuiLinearProgress-bar': {
backgroundColor: 'var(--joy-palette-neutral-400)',
},
}}
/>
<Typography level='body-xs' sx={{ opacity: 0.6, mb: 1 }}>
-- MB used / -- MB total (--)
</Typography>
<Typography level='body-sm' color='warning'>
Server storage is not available in the Basic plan. Upgrade to
Plus to track your server storage usage.
</Typography>
</>
) : loading ? (
<>
<LinearProgress sx={{ mb: 1 }} />
<Typography level='body-xs'>Loading...</Typography>
</>
) : (
<>
<LinearProgress determinate value={percent} sx={{ mb: 1 }} />
<Typography level='body-xs'>
{usedMB} MB used / {totalMB} MB total ({percent}%)
</Typography>
</>
)}
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
This is the storage used by your account on our servers (e.g. files,
images, and data you have uploaded).
</Typography>
{!isPlusAccount(userProfile) ? (
<>
<LinearProgress
determinate
value={0}
sx={{
mb: 1,
opacity: 0.4,
'& .MuiLinearProgress-bar': {
backgroundColor: 'var(--joy-palette-neutral-400)',
</Card>
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
Cache
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
This is data stored locally in your browser for faster access and
offline use. Clearing this will not affect your server data, but may
log you out or remove offline tasks.
</Typography>
<Button
variant='soft'
color='danger'
onClick={() => {
showConfirmation(
'Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.',
'Clear All Local Storage',
() => {
localStorage.clear()
Navigate('/login')
},
}}
/>
<Typography level='body-xs' sx={{ opacity: 0.6, mb: 1 }}>
-- MB used / -- MB total (--)
</Typography>
<Typography level='body-sm' color='warning'>
Server storage monitoring is not available in the Basic plan.
Upgrade to Plus to track your server storage usage.
</Typography>
</>
) : loading ? (
<>
<LinearProgress sx={{ mb: 1 }} />
<Typography level='body-xs'>Loading...</Typography>
</>
) : (
<>
<LinearProgress determinate value={percent} sx={{ mb: 1 }} />
<Typography level='body-xs'>
{usedMB} MB used / {totalMB} MB total ({percent}%)
</Typography>
</>
)}
</Card>
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
Cache
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
This is data stored locally in your browser for faster access and
offline use. Clearing this will not affect your server data, but may
log you out or remove offline tasks.
</Typography>
<Button
variant='soft'
color='danger'
onClick={() => {
showConfirmation(
'Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.',
'Clear All Local Storage',
() => {
localStorage.clear()
Navigate('/login')
},
'Clear All',
'Cancel',
'danger',
)
}}
>
Clear All Local Storage and Cache
</Button>
<Button
variant='outlined'
color='danger'
onClick={() => {
showConfirmation(
'Are you sure you want to clear only the offline cache and tasks?',
'Clear Offline Cache',
() => {
localStorage.removeItem('offline_cache')
localStorage.removeItem('offline_request_queue')
localStorage.removeItem('offlineTasks')
},
'Clear Cache',
'Cancel',
'danger',
)
}}
sx={{ mt: 1 }}
>
Clear Offline Cache and Offline Tasks
</Button>
</Card>
'Clear All',
'Cancel',
'danger',
)
}}
>
Clear All Local Storage and Cache
</Button>
<Button
variant='outlined'
color='danger'
onClick={() => {
showConfirmation(
'Are you sure you want to clear only the offline cache and tasks?',
'Clear Offline Cache',
() => {
localStorage.removeItem('offline_cache')
localStorage.removeItem('offline_request_queue')
localStorage.removeItem('offlineTasks')
},
'Clear Cache',
'Cancel',
'danger',
)
}}
sx={{ mt: 1 }}
>
Clear Offline Cache and Offline Tasks
</Button>
</Card>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</div>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</div>
</SettingsLayout>
)
}

View File

@@ -0,0 +1,19 @@
import { Typography } from '@mui/joy'
import SettingsLayout from './SettingsLayout'
import ThemeToggle from './ThemeToggle'
const ThemeSettings = () => {
return (
<SettingsLayout title="Theme Preferences">
<div className='grid gap-4'>
<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>
<ThemeToggle />
</div>
</SettingsLayout>
)
}
export default ThemeSettings

View File

@@ -2,190 +2,244 @@ import React from 'react'
const TermsView = () => {
return (
<div>
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px', lineHeight: '1.6' }}>
<h1>Terms of Service</h1>
<p><em>Effective Date: January 1, 2024</em></p>
<p>
These Terms of Service ("Terms") govern your access to and use of the
services provided by Favoro LLC, doing business as donetick.com
("Favoro", "we", "us", or "our"). By accessing or using our website and
services, you agree to be bound by these Terms. If you do not agree to
Donetick task management platform provided by Favoro LLC ("Donetick", "we", "us", or "our").
By accessing or using our services, you agree to be bound by these Terms. If you do not agree to
these Terms, you may not access or use our services.
</p>
<h2>Use of Services</h2>
<div style={{ backgroundColor: '#f5f5f5', padding: '15px', borderRadius: '5px', margin: '20px 0' }}>
<h3>Important: Cloud vs. Self-Hosted Services</h3>
<p>
Donetick is available in two deployment models with different terms and responsibilities:
</p>
<ul>
<li><strong>Cloud-Hosted Service:</strong> Hosted at donetick.com where we provide infrastructure and data management</li>
<li><strong>Self-Hosted Service:</strong> Open-source software you deploy on your own infrastructure</li>
</ul>
<p>Please review the applicable sections carefully as they define different rights and obligations.</p>
</div>
<h2>1. Definitions</h2>
<ul>
<li>
You must be at least 18 years old or have the legal capacity to enter
into contracts in your jurisdiction to use our services.
</li>
<li>
You are responsible for maintaining the confidentiality of your
account credentials and for any activity that occurs under your
account.
</li>
<li>
You may not use our services for any illegal or unauthorized purpose,
or in any way that violates these Terms.
</li>
</ul>
<h2>Subscriptions</h2>
<ul>
<li>
Some parts of the Service are billed on a subscription basis
("Subscription(s)"). You will be billed in advance on a recurring and
periodic basis ("Billing Cycle"). Billing cycles are set either on a
monthly or annual basis, depending on the type of subscription plan
you select when purchasing a Subscription.
</li>
<li>
At the end of each Billing Cycle, your Subscription will automatically
renew under the exact same conditions unless you cancel it or Favoro
cancels it. You may cancel your Subscription renewal either through
your online account management page or by contacting Donetickcustomer
support team.
</li>
<li>
A valid payment method, including credit or debit card, is required to
process the payment for your Subscription. You shall provide Favoro
with accurate and complete billing information including full name,
address, state, zip code, telephone number, and a valid payment method
information. By submitting such payment information, you automatically
authorize Donetickto charge all Subscription fees incurred through
your account to any such payment instruments.
</li>
<li>
Should automatic billing fail to occur for any reason, Donetickwill
issue an electronic invoice indicating that you must proceed manually,
within a certain deadline date, with the full payment corresponding to
the billing period as indicated on the invoice.
</li>
<li><strong>"Cloud Service"</strong> refers to the Donetick platform hosted at donetick.com and managed by Favoro LLC</li>
<li><strong>"Self-Hosted Service"</strong> refers to the open-source Donetick software deployed on user-controlled infrastructure</li>
<li><strong>"Content"</strong> means all data, information, tasks, files, and other materials you create, upload, or store using our services</li>
<li><strong>"Circle"</strong> means a collaborative workspace shared between users for task management</li>
</ul>
<h2>Fee Changes</h2>
<h2>2. Eligibility and Account Registration</h2>
<ul>
<li>
{' '}
Favoro, in its sole discretion and at any time, may modify the
Subscription fees for the Subscriptions. Any Subscription fee change
will become effective at the end of the then-current Billing Cycle.
</li>
<li>
Donetickwill provide you with reasonable prior notice of any change in
Subscription fees to give you an opportunity to terminate your
Subscription before such change becomes effective.
</li>
<li>You must be at least 13 years old to use our services</li>
<li>Users under 18 must have parental consent</li>
<li>You are responsible for maintaining the confidentiality of your account credentials</li>
<li>You must provide accurate and complete information when creating an account</li>
<li>One person or legal entity may maintain only one account</li>
</ul>
<h2>Refunds</h2>
<h2>3. Cloud-Hosted Service Terms</h2>
<p><em>This section applies only to users of the Cloud Service at donetick.com</em></p>
<h3>3.1 Service Availability and Uptime</h3>
<ul>
<li>
Certain refund requests for Subscriptions may be considered by Favoro
on a case-by-case basis and granted at the sole discretion of Favoro.
</li>
<li>We strive to maintain 99.5% uptime for the Cloud Service on a monthly basis</li>
<li>Scheduled maintenance will be announced at least 24 hours in advance when possible</li>
<li>We reserve the right to temporarily suspend service for emergency maintenance</li>
<li>No SLA credits or compensation are provided for downtime unless otherwise specified in a separate agreement</li>
</ul>
<h2>Content</h2>
<h3>3.2 Data Ownership and Responsibility</h3>
<ul>
<li>
Our services allow you to post, link, store, share, and otherwise make
available certain information, text, graphics, videos, or other
material ("Content").
</li>
<li>
You are responsible for the Content that you post to our services,
including its legality, reliability, and appropriateness.
</li>
<li>
You may not post Content that is defamatory, obscene, abusive,
offensive, or otherwise objectionable.
</li>
<li>
You may not post Content that violates any party's intellectual
property rights.
</li>
<li> You may not post Content that violates any law or regulation.</li>
<li><strong>Your Data:</strong> You retain ownership of all Content you create or upload</li>
<li><strong>Our Responsibility:</strong> We provide secure hosting, backup, and infrastructure management</li>
<li><strong>Data Portability:</strong> You can export your data at any time through our export features</li>
<li><strong>Data Retention:</strong> We retain your data for 90 days after account deletion to allow recovery</li>
</ul>
<h2>Feedback Requests</h2>
<h3>3.3 Subscriptions and Billing</h3>
<ul>
<li>Cloud Service subscriptions are billed monthly or annually in advance</li>
<li>Subscription fees will automatically renew unless cancelled before the next billing cycle</li>
<li>You may cancel your subscription at any time through your account settings</li>
<li>Upon cancellation, you retain access until the end of your current billing period</li>
<li>We may modify subscription prices with 30 days advance notice</li>
<li>Refunds are considered on a case-by-case basis at our discretion</li>
</ul>
<h3>3.4 Cloud Service Limitations</h3>
<ul>
<li>Storage limits apply based on your subscription tier</li>
<li>API rate limits may be enforced to ensure service stability</li>
<li>We may suspend accounts that exceed reasonable usage limits</li>
</ul>
<h2>4. Self-Hosted Service Terms</h2>
<p><em>This section applies to users deploying Donetick on their own infrastructure</em></p>
<h3>4.1 Open Source License</h3>
<ul>
<li>The Self-Hosted Service is provided under the MIT License</li>
<li>You may modify, distribute, and use the software for any purpose</li>
<li>Attribution to Donetick must be maintained in derivative works</li>
<li>No warranty or support is provided for self-hosted deployments</li>
</ul>
<h3>4.2 User Responsibility</h3>
<ul>
<li><strong>Infrastructure:</strong> You are solely responsible for hosting, maintenance, security, and backups</li>
<li><strong>Data Protection:</strong> You are the data controller and responsible for compliance with applicable laws</li>
<li><strong>Updates:</strong> You are responsible for applying security updates and patches</li>
<li><strong>Support:</strong> No technical support is provided for self-hosted installations</li>
</ul>
<h3>4.3 Limitation of Our Responsibility</h3>
<ul>
<li>We provide no guarantees about the performance or security of self-hosted deployments</li>
<li>We are not responsible for any data loss, security breaches, or service interruptions in self-hosted environments</li>
<li>Technical support is limited to community forums and documentation</li>
</ul>
<h2>5. Acceptable Use Policy</h2>
<p><em>Applies to both Cloud and Self-Hosted Services</em></p>
<h3>You may not use our services to:</h3>
<ul>
<li>Violate any applicable laws or regulations</li>
<li>Infringe on intellectual property rights</li>
<li>Transmit malicious code or conduct security attacks</li>
<li>Harass, abuse, or harm other users</li>
<li>Distribute spam or unwanted communications</li>
<li>Attempt to reverse engineer our proprietary systems (Cloud Service)</li>
<li>Resell or redistribute the service without permission</li>
</ul>
<h2>6. Content and Data</h2>
<h3>6.1 Your Content Rights</h3>
<ul>
<li>You retain ownership of all Content you create</li>
<li>You grant us the necessary rights to operate the service (Cloud Service only)</li>
<li>You are responsible for ensuring you have rights to any Content you upload</li>
</ul>
<h3>6.2 Content Restrictions</h3>
<ul>
<li>Content must not violate any laws or third-party rights</li>
<li>Content must not contain malicious code or harmful materials</li>
<li>We may remove Content that violates these Terms (Cloud Service only)</li>
</ul>
<h2>7. Privacy and Security</h2>
<ul>
<li>Your privacy is important to us - please review our Privacy Policy</li>
<li>We implement industry-standard security measures (Cloud Service)</li>
<li>You are responsible for security in self-hosted deployments</li>
<li>Report security vulnerabilities to security@donetick.com</li>
</ul>
<h2>8. Intellectual Property</h2>
<ul>
<li>The Donetick name, logo, and proprietary features are our intellectual property</li>
<li>The open-source codebase is licensed under MIT License</li>
<li>You may not use our trademarks without written permission</li>
</ul>
<h2>9. Third-Party Integrations</h2>
<ul>
<li>Donetick integrates with third-party services (Telegram, Discord, webhooks, etc.)</li>
<li>Your use of these integrations is subject to their respective terms</li>
<li>We are not responsible for third-party service availability or functionality</li>
</ul>
<h2>10. Liability and Warranties</h2>
<h3>10.1 Disclaimer of Warranties</h3>
<p>
Our platform allows users to send feedback requests to others. You are
solely responsible for the content of any feedback requests you send
using our services.
Our services are provided "as is" and "as available" without any warranty of any kind,
express or implied, including but not limited to merchantability, fitness for a particular purpose,
or non-infringement.
</p>
<h3>10.2 Cloud Service Liability</h3>
<ul>
<li>Our total liability for the Cloud Service is limited to the amount you paid in the 12 months preceding the claim</li>
<li>We are not liable for indirect, incidental, special, or consequential damages</li>
<li>We maintain appropriate insurance and implement security best practices</li>
</ul>
<h3>10.3 Self-Hosted Service Liability</h3>
<ul>
<li>We provide no warranties or guarantees for self-hosted deployments</li>
<li>Our liability is limited to the maximum extent permitted by law</li>
<li>You assume all risks associated with self-hosting</li>
</ul>
<h2>11. Indemnification</h2>
<p>
You may not use our services to send spam, harass others, or engage in
any abusive behavior.
You agree to indemnify and hold us harmless from any claims, damages, or expenses
arising from your use of our services, violation of these Terms, or infringement of any rights.
</p>
<h2>Credits</h2>
<h2>12. Termination</h2>
<h3>12.1 Termination by You</h3>
<ul>
<li>You may terminate your account at any time</li>
<li>Cloud Service: Access continues until the end of your billing period</li>
<li>Self-Hosted Service: You may stop using the software at any time</li>
</ul>
<h3>12.2 Termination by Us</h3>
<ul>
<li>We may terminate accounts that violate these Terms</li>
<li>We may discontinue the Cloud Service with 90 days notice</li>
<li>We will provide data export capabilities before termination when possible</li>
</ul>
<h2>13. Changes to These Terms</h2>
<ul>
<li>We may update these Terms from time to time</li>
<li>Material changes will be announced via email or in-app notification</li>
<li>Continued use after changes constitutes acceptance of new Terms</li>
<li>For significant changes, we may require explicit acceptance</li>
</ul>
<h2>14. Dispute Resolution</h2>
<ul>
<li>We encourage resolving disputes informally by contacting us first</li>
<li>These Terms are governed by the laws of Delaware, United States</li>
<li>Any disputes will be resolved in the courts of Delaware</li>
<li>You may pursue small claims court for eligible disputes</li>
</ul>
<h2>15. Miscellaneous</h2>
<ul>
<li>If any provision is found unenforceable, the remainder remains in effect</li>
<li>Our failure to enforce any right does not waive that right</li>
<li>These Terms constitute the entire agreement between us</li>
<li>We may assign these Terms; you may not without our consent</li>
</ul>
<h2>16. Contact Information</h2>
<p>
Certain actions on our platform may require credits. You can purchase
credits through our website.
If you have questions about these Terms, please contact us:
</p>
<ul>
<li><strong>Email:</strong> legal@donetick.com</li>
<li><strong>Support:</strong> support@donetick.com</li>
<li><strong>Address:</strong> Favoro LLC, [Address to be provided]</li>
</ul>
<p>Credits are non-refundable and non-transferable.</p>
<h2>Intellectual Property</h2>
<p>
All content on our website and services, including text, graphics,
logos, and images, is the property of Donetickor its licensors and is
protected by copyright and other intellectual property laws.
</p>
<p>
You may not reproduce, modify, or distribute any content from our
website or services without our prior written consent.
</p>
<h2>Disclaimer of Warranties</h2>
<p>
Our services are provided "as is" and "as available" without any
warranty of any kind, express or implied.
</p>
<p>
We do not warrant that our services will be uninterrupted, secure, or
error-free, or that any defects will be corrected.
</p>
<h2>Limitation of Liability</h2>
<p>
In no event shall Donetickbe liable for any indirect, incidental,
special, consequential, or punitive damages, including but not limited
to lost profits, arising out of or in connection with your use of our
services.
</p>
<h2>Governing Law</h2>
<p>
These Terms shall be governed by and construed in accordance with the
laws of the state of [Your State/Country], without regard to its
conflict of law principles.
</p>
<h2>Changes to These Terms</h2>
<p>
We may update these Terms from time to time. Any changes will be posted
on this page, and the revised date will be indicated at the top of the
page. Your continued use of our services after any such changes
constitutes your acceptance of the new Terms.
</p>
<h2>Contact Us</h2>
<p>
If you have any questions or concerns about these Terms, please contact
us at support@donetick.com
<hr style={{ margin: '40px 0' }} />
<p style={{ fontSize: '14px', color: '#666' }}>
<strong>Last Updated:</strong> January 1, 2024<br />
These Terms of Service are effective immediately for new users and will become effective
for existing users 30 days after posting.
</p>
</div>
)

View File

@@ -668,7 +668,7 @@ const ThingsView = () => {
return (
<Container maxWidth='md' sx={{ px: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
<Stack sx={{ flex: 1 }}>
<Typography

View File

@@ -1,5 +1,5 @@
import { Add, EditNotifications } from '@mui/icons-material'
import { Box, Button, Chip, Input, Option, Select, Typography } from '@mui/joy'
import { Box, Button, Input, Option, Select, Typography } from '@mui/joy'
import { FormControl } from '@mui/material'
import * as chrono from 'chrono-node'
import moment from 'moment'
@@ -473,9 +473,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
fullWidth={true}
title='Create new task'
>
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
Experimental Feature
</Chip>
<Box>
<Box
sx={{

View File

@@ -1,7 +1,7 @@
import { CalendarMonth } from '@mui/icons-material'
import { Avatar, Box, Chip, Grid, Typography } from '@mui/joy'
import moment from 'moment'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getPriorityColor, TASK_COLOR } from '../../utils/Colors'
@@ -13,7 +13,7 @@ const getAssigneeColor = (assignee, userProfile) => {
? TASK_COLOR.ASSIGNED_TO_ME
: TASK_COLOR.ASSIGNED_TO_OTHER
}
const CalendarView = ({ chores }) => {
const CalendarCard = ({ chores }) => {
const { data: userProfile } = useUserProfile()
const [selectedDate, setSeletedDate] = useState(null)
@@ -23,6 +23,33 @@ const CalendarView = ({ chores }) => {
const { data: circleMembersData } = useCircleMembers()
const circleMembers = circleMembersData?.res || []
// Auto-update selected calendar date when day changes
useEffect(() => {
if (!selectedDate) {
return
}
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(0, 0, 0, 0) // Set to start of tomorrow
const msUntilTomorrow = tomorrow.getTime() - now.getTime()
const timeout = setTimeout(() => {
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
// Check if selected date is now yesterday and update to today
if (selectedDate.toDateString() === yesterday.toDateString()) {
setSeletedDate(today)
}
}, msUntilTomorrow)
return () => clearTimeout(timeout)
}, [selectedDate])
// Helper function to get assignee display name
const getAssigneeName = assignedTo => {
if (assignedTo === userProfile.id) {
@@ -307,4 +334,4 @@ const CalendarView = ({ chores }) => {
)
}
export default CalendarView
export default CalendarCard

View File

@@ -118,6 +118,7 @@ export const parseLabels = (inputSentence, userLabels) => {
export const parseRepeatV2 = inputSentence => {
const sentence = inputSentence.toLowerCase()
const lowerInputSentence = inputSentence.toLowerCase()
const result = {
frequency: 1,
frequencyType: null,
@@ -232,8 +233,8 @@ export const parseRepeatV2 = inputSentence => {
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
start: lowerInputSentence.indexOf(match[0]),
end: lowerInputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
@@ -250,8 +251,8 @@ export const parseRepeatV2 = inputSentence => {
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
start: lowerInputSentence.indexOf(match[0]),
end: lowerInputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
@@ -275,8 +276,8 @@ export const parseRepeatV2 = inputSentence => {
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
start: lowerInputSentence.indexOf(match[0]),
end: lowerInputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
@@ -299,8 +300,8 @@ export const parseRepeatV2 = inputSentence => {
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
start: lowerInputSentence.indexOf(match[0]),
end: lowerInputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
@@ -315,8 +316,8 @@ export const parseRepeatV2 = inputSentence => {
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
start: lowerInputSentence.indexOf(match[0]),
end: lowerInputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
@@ -332,8 +333,8 @@ export const parseRepeatV2 = inputSentence => {
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
start: lowerInputSentence.indexOf(match[0]),
end: lowerInputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
@@ -351,8 +352,8 @@ export const parseRepeatV2 = inputSentence => {
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
start: lowerInputSentence.indexOf(match[0]),
end: lowerInputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
@@ -367,8 +368,8 @@ export const parseRepeatV2 = inputSentence => {
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
start: lowerInputSentence.indexOf(match[0]),
end: lowerInputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),

View File

@@ -23,7 +23,7 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { version } from '../../../package.json'
import UserProfileAvatar from '../../components/UserProfileAvatar'
import NavBarLink from './NavBarLink'
@@ -97,6 +97,7 @@ const NavBar = () => {
() => setDrawerOpen(false),
]
const location = useLocation()
const [searchParams] = useSearchParams()
useEffect(() => {
SafeArea.getSafeAreaInsets().then(data => {
const { insets } = data
@@ -150,7 +151,8 @@ const NavBar = () => {
backgroundColor: 'var(--joy-palette-background-body)',
}}
>
{['/chores', '/'].includes(location.pathname) ? (
{['/chores', '/'].includes(location.pathname) &&
!searchParams.get('filter') ? (
<IconButton
size='md'
variant='plain'
@@ -159,7 +161,24 @@ const NavBar = () => {
<MenuRounded />
</IconButton>
) : (
<IconButton size='md' variant='plain' onClick={() => navigate(-1)}>
<IconButton
size='md'
variant='plain'
onClick={() => {
if (location.pathname === '/chores') {
// Navigate back to calendar view
navigate('/')
} else {
// Default back navigation
navigate(-1)
}
}}
title={
searchParams.get('from') === 'calendar'
? 'Back to Calendar'
: 'Back'
}
>
<ArrowBack />
</IconButton>
)}
@@ -192,9 +211,18 @@ const NavBar = () => {
</div> */}
<List
// sx={{ p: 2, height: 'min-content' }}
size='md'
onClick={openDrawer}
sx={{ borderRadius: 4, width: '100%', padding: 1 }}
sx={{
borderRadius: 4,
width: '100%',
padding: 1,
paddingTop:
Capacitor.getPlatform() === 'android'
? `calc(var(--safe-area-inset-top, 0px))`
: '',
}}
>
{links.map((link, index) => (
<NavBarLink key={index} link={link} />