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:
@@ -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.
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
|
||||
31
src/App.jsx
31
src/App.jsx
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
39
src/hooks/useAcknowledgmentModal.js
Normal file
39
src/hooks/useAcknowledgmentModal.js
Normal 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
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -425,6 +425,7 @@ const LoginView = () => {
|
||||
label='Password'
|
||||
type='password'
|
||||
id='password'
|
||||
autoComplete='password'
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
108
src/views/Modals/Inputs/AcknowledgmentModal.jsx
Normal file
108
src/views/Modals/Inputs/AcknowledgmentModal.jsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
308
src/views/Settings/AccountSettings.jsx
Normal file
308
src/views/Settings/AccountSettings.jsx
Normal 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
|
||||
163
src/views/Settings/AdvancedSettings.jsx
Normal file
163
src/views/Settings/AdvancedSettings.jsx
Normal 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
|
||||
489
src/views/Settings/CircleSettings.jsx
Normal file
489
src/views/Settings/CircleSettings.jsx
Normal 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
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
12
src/views/Settings/NotificationSettings.jsx
Normal file
12
src/views/Settings/NotificationSettings.jsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
21
src/views/Settings/SettingsLayout.jsx
Normal file
21
src/views/Settings/SettingsLayout.jsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
7
src/views/Settings/SettingsRoutes.jsx
Normal file
7
src/views/Settings/SettingsRoutes.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
const SettingsRoutes = () => {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export default SettingsRoutes
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
19
src/views/Settings/ThemeSettings.jsx
Normal file
19
src/views/Settings/ThemeSettings.jsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user