aboutsummaryrefslogtreecommitdiffstats
path: root/src/views
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/views/Authorization/AuthorizationContainer.jsx45
-rw-r--r--src/views/Authorization/ForgotPasswordView.jsx227
-rw-r--r--src/views/Authorization/LoginView.jsx345
-rw-r--r--src/views/Authorization/Signup.jsx243
-rw-r--r--src/views/Authorization/UpdatePasswordView.jsx194
-rw-r--r--src/views/ChoreEdit/ChoreEdit.jsx744
-rw-r--r--src/views/ChoreEdit/RepeatSection.jsx496
-rw-r--r--src/views/ChoreEdit/ThingTriggerSection.jsx230
-rw-r--r--src/views/Chores/ChoreCard.jsx578
-rw-r--r--src/views/Chores/MyChores.jsx384
-rw-r--r--src/views/ChoresOverview.jsx354
-rw-r--r--src/views/Circles/JoinCircle.jsx154
-rw-r--r--src/views/Error.jsx11
-rw-r--r--src/views/History/BigChip.jsx26
-rw-r--r--src/views/History/ChoreHistory.jsx344
-rw-r--r--src/views/History/InfoCard.jsx26
-rw-r--r--src/views/Home.jsx46
-rw-r--r--src/views/Landing/FeaturesSection.jsx139
-rw-r--r--src/views/Landing/HomeHero.jsx186
-rw-r--r--src/views/Landing/Landing.jsx32
-rw-r--r--src/views/Landing/PricingSection.jsx179
-rw-r--r--src/views/Modals/Inputs/ConfirmationModal.jsx43
-rw-r--r--src/views/Modals/Inputs/CreateThingModal.jsx112
-rw-r--r--src/views/Modals/Inputs/DateModal.jsx45
-rw-r--r--src/views/Modals/Inputs/SelectModal.jsx49
-rw-r--r--src/views/Modals/Inputs/TextModal.jsx46
-rw-r--r--src/views/NotificationTargets/EditNotificationTarget.jsx51
-rw-r--r--src/views/Payments/PaymentFailView.jsx51
-rw-r--r--src/views/Payments/PaymentSuccessView.jsx51
-rw-r--r--src/views/PrivacyPolicy/PrivacyPolicyView.jsx102
-rw-r--r--src/views/Settings/APITokenSettings.jsx130
-rw-r--r--src/views/Settings/NotificationSetting.jsx90
-rw-r--r--src/views/Settings/Settings.jsx384
-rw-r--r--src/views/Settings/Sharing.jsx0
-rw-r--r--src/views/Settings/SharingSettings.jsx0
-rw-r--r--src/views/Settings/ThemeToggle.jsx62
-rw-r--r--src/views/SummaryCard.jsx31
-rw-r--r--src/views/Terms/TermsView.jsx194
-rw-r--r--src/views/TestView/IconPicker.jsx58
-rw-r--r--src/views/TestView/Test.jsx11
-rw-r--r--src/views/Things/ThingsHistory.jsx13
-rw-r--r--src/views/Things/ThingsView.jsx324
-rw-r--r--src/views/components/AutocompleteSelect.jsx87
-rw-r--r--src/views/components/NavBar.jsx177
-rw-r--r--src/views/components/NavBarLink.jsx31
-rw-r--r--src/views/components/NavBarMobile.jsx107
46 files changed, 7232 insertions, 0 deletions
diff --git a/src/views/Authorization/AuthorizationContainer.jsx b/src/views/Authorization/AuthorizationContainer.jsx
new file mode 100644
index 0000000..3bfc622
--- /dev/null
+++ b/src/views/Authorization/AuthorizationContainer.jsx
@@ -0,0 +1,45 @@
+// import Logo from 'Components/Logo'
+import { Box, Paper } from '@mui/material'
+import { styled } from '@mui/material/styles'
+
+const Container = styled('div')(({ theme }) => ({
+ minHeight: '100vh',
+ padding: '24px',
+ display: 'grid',
+ placeItems: 'start center',
+ [theme.breakpoints.up('sm')]: {
+ // center children
+ placeItems: 'center',
+ },
+}))
+
+const AuthCard = styled(Paper)(({ theme }) => ({
+ // border: "1px solid #c4c4c4",
+ padding: 24,
+ paddingTop: 32,
+ borderRadius: 24,
+ width: '100%',
+ maxWidth: '400px',
+ [theme.breakpoints.down('sm')]: {
+ maxWidth: 'unset',
+ },
+}))
+
+export default function AuthCardContainer({ children, ...props }) {
+ return (
+ <Container>
+ <AuthCard elevation={0}>
+ <Box
+ sx={{
+ display: 'grid',
+ placeItems: 'center',
+ paddingBottom: 4,
+ }}
+ >
+ {/* <Logo size='96px' /> */}
+ </Box>
+ {children}
+ </AuthCard>
+ </Container>
+ )
+}
diff --git a/src/views/Authorization/ForgotPasswordView.jsx b/src/views/Authorization/ForgotPasswordView.jsx
new file mode 100644
index 0000000..44601eb
--- /dev/null
+++ b/src/views/Authorization/ForgotPasswordView.jsx
@@ -0,0 +1,227 @@
+// create boilerplate for ResetPasswordView:
+import {
+ Box,
+ Button,
+ Container,
+ FormControl,
+ FormHelperText,
+ Input,
+ Sheet,
+ Snackbar,
+ Typography,
+} from '@mui/joy'
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import { API_URL } from './../../Config'
+
+const ForgotPasswordView = () => {
+ const navigate = useNavigate()
+ // const [showLoginSnackbar, setShowLoginSnackbar] = useState(false)
+ // const [snackbarMessage, setSnackbarMessage] = useState('')
+ const [resetStatusOk, setResetStatusOk] = useState(null)
+ const [email, setEmail] = useState('')
+ const [emailError, setEmailError] = useState(null)
+
+ const validateEmail = email => {
+ return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
+ }
+
+ const handleSubmit = async () => {
+ if (!email) {
+ return setEmailError('Email is required')
+ }
+
+ // validate email:
+ if (validateEmail(email)) {
+ setEmailError('Please enter a valid email address')
+ return
+ }
+
+ if (emailError) {
+ return
+ }
+
+ try {
+ const response = await fetch(`${API_URL}/auth/reset`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ email: email }),
+ })
+
+ if (response.ok) {
+ setResetStatusOk(true)
+ // wait 3 seconds and then redirect to login:
+ } else {
+ setResetStatusOk(false)
+ }
+ } catch (error) {
+ setResetStatusOk(false)
+ }
+ }
+
+ const handleEmailChange = e => {
+ setEmail(e.target.value)
+ if (validateEmail(e.target.value)) {
+ setEmailError('Please enter a valid email address')
+ } else {
+ setEmailError(null)
+ }
+ }
+
+ return (
+ <Container
+ component='main'
+ maxWidth='xs'
+
+ // make content center in the middle of the page:
+ >
+ <Box
+ sx={{
+ marginTop: 4,
+ display: 'flex',
+ flexDirection: 'column',
+
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ }}
+ >
+ <Sheet
+ component='form'
+ sx={{
+ mt: 1,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: 2,
+ borderRadius: '8px',
+ boxShadow: 'md',
+ minHeight: '70vh',
+ justifyContent: 'space-between',
+ justifyItems: 'center',
+ }}
+ >
+ <Box>
+ <img
+ src='/src/assets/logo.svg'
+ alt='logo'
+ width='128px'
+ height='128px'
+ />
+ {/* <Logo /> */}
+ <Typography level='h2'>
+ Done
+ <span
+ style={{
+ color: '#06b6d4',
+ }}
+ >
+ tick
+ </span>
+ </Typography>
+ </Box>
+ {/* HERE */}
+ <Box sx={{ textAlign: 'center' }}></Box>
+ {resetStatusOk === null && (
+ <form onSubmit={handleSubmit}>
+ <div className='grid gap-6'>
+ <Typography level='body2' gutterBottom>
+ Enter your email, and we'll send you a link to get into your
+ account.
+ </Typography>
+ <FormControl error={emailError !== null}>
+ <Input
+ placeholder='Email'
+ type='email'
+ variant='soft'
+ fullWidth
+ size='lg'
+ value={email}
+ onChange={handleEmailChange}
+ error={emailError !== null}
+ onKeyDown={e => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleSubmit()
+ }
+ }}
+ />
+ <FormHelperText>{emailError}</FormHelperText>
+ </FormControl>
+ <Box>
+ <Button
+ variant='solid'
+ size='lg'
+ fullWidth
+ sx={{
+ mb: 1,
+ }}
+ onClick={handleSubmit}
+ >
+ Reset Password
+ </Button>
+ <Button
+ fullWidth
+ size='lg'
+ variant='soft'
+ sx={{
+ width: '100%',
+ border: 'moccasin',
+ borderRadius: '8px',
+ }}
+ onClick={() => {
+ navigate('/login')
+ }}
+ color='neutral'
+ >
+ Back to Login
+ </Button>
+ </Box>
+ </div>
+ </form>
+ )}
+ {resetStatusOk != null && (
+ <>
+ <Box mt={-30}>
+ <Typography level='body-md'>
+ if there is an account associated with the email you entered,
+ you will receive an email with instructions on how to reset
+ your
+ </Typography>
+ </Box>
+ <Button
+ variant='soft'
+ size='lg'
+ sx={{ position: 'relative', bottom: '0' }}
+ onClick={() => {
+ navigate('/login')
+ }}
+ fullWidth
+ >
+ Go to Login
+ </Button>
+ </>
+ )}
+ <Snackbar
+ open={resetStatusOk ? resetStatusOk : resetStatusOk === false}
+ autoHideDuration={5000}
+ onClose={() => {
+ if (resetStatusOk) {
+ navigate('/login')
+ }
+ }}
+ >
+ {resetStatusOk
+ ? 'Reset email sent, check your email'
+ : 'Reset email failed, try again later'}
+ </Snackbar>
+ </Sheet>
+ </Box>
+ </Container>
+ )
+}
+
+export default ForgotPasswordView
diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx
new file mode 100644
index 0000000..2ffcef4
--- /dev/null
+++ b/src/views/Authorization/LoginView.jsx
@@ -0,0 +1,345 @@
+import GoogleIcon from '@mui/icons-material/Google'
+import {
+ Avatar,
+ Box,
+ Button,
+ Container,
+ Divider,
+ Input,
+ Sheet,
+ Snackbar,
+ Typography,
+} from '@mui/joy'
+import Cookies from 'js-cookie'
+import React from 'react'
+import { useNavigate } from 'react-router-dom'
+import { LoginSocialGoogle } from 'reactjs-social-login'
+import { API_URL, GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
+import { UserContext } from '../../contexts/UserContext'
+import Logo from '../../Logo'
+import { GetUserProfile } from '../../utils/Fetcher'
+const LoginView = () => {
+ const { userProfile, setUserProfile } = React.useContext(UserContext)
+ const [username, setUsername] = React.useState('')
+ const [password, setPassword] = React.useState('')
+ const [error, setError] = React.useState(null)
+ const Navigate = useNavigate()
+ const handleSubmit = async e => {
+ e.preventDefault()
+
+ fetch(`${API_URL}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ username, password }),
+ })
+ .then(response => {
+ if (response.status === 200) {
+ return response.json().then(data => {
+ localStorage.setItem('ca_token', data.token)
+ localStorage.setItem('ca_expiration', data.expire)
+ const redirectUrl = Cookies.get('ca_redirect')
+ // console.log('redirectUrl', redirectUrl)
+ if (redirectUrl) {
+ Cookies.remove('ca_redirect')
+ Navigate(redirectUrl)
+ } else {
+ Navigate('/my/chores')
+ }
+ })
+ } else if (response.status === 401) {
+ setError('Wrong username or password')
+ } else {
+ setError('An error occurred, please try again')
+ console.log('Login failed')
+ }
+ })
+ .catch(err => {
+ setError('Unable to communicate with server, please try again')
+ console.log('Login failed', err)
+ })
+ }
+
+ const loggedWithProvider = function (provider, data) {
+ console.log(provider, data)
+ return fetch(API_URL + `/auth/${provider}/callback`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ provider: provider,
+ token:
+ data['access_token'] || // data["access_token"] is for Google
+ data['accessToken'], // data["accessToken"] is for Facebook
+ data: data,
+ }),
+ }).then(response => {
+ if (response.status === 200) {
+ return response.json().then(data => {
+ localStorage.setItem('ca_token', data.token)
+ localStorage.setItem('ca_expiration', data.expire)
+ // setIsLoggedIn(true);
+ getUserProfileAndNavigateToHome()
+ })
+ }
+ return response.json().then(error => {
+ setError("Couldn't log in with Google, please try again")
+ })
+ })
+ }
+ const getUserProfileAndNavigateToHome = () => {
+ GetUserProfile().then(data => {
+ data.json().then(data => {
+ setUserProfile(data.res)
+ // check if redirect url is set in cookie:
+ const redirectUrl = Cookies.get('ca_redirect')
+ if (redirectUrl) {
+ Cookies.remove('ca_redirect')
+ Navigate(redirectUrl)
+ } else {
+ Navigate('/my/chores')
+ }
+ })
+ })
+ }
+ const handleForgotPassword = () => {
+ Navigate('/forgot-password')
+ }
+ return (
+ <Container
+ component='main'
+ maxWidth='xs'
+
+ // make content center in the middle of the page:
+ >
+ <Box
+ sx={{
+ marginTop: 4,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ }}
+ >
+ <Sheet
+ component='form'
+ sx={{
+ mt: 1,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: 2,
+ borderRadius: '8px',
+ boxShadow: 'md',
+ }}
+ >
+ {/* <img
+ src='/src/assets/logo.svg'
+ alt='logo'
+ width='128px'
+ height='128px'
+ /> */}
+ <Logo />
+
+ <Typography level='h2'>
+ Done
+ <span
+ style={{
+ color: '#06b6d4',
+ }}
+ >
+ tick
+ </span>
+ </Typography>
+
+ {userProfile && (
+ <>
+ <Avatar
+ src={userProfile?.image}
+ alt={userProfile?.username}
+ size='lg'
+ sx={{
+ mt: 2,
+ width: '96px',
+ height: '96px',
+ mb: 1,
+ }}
+ />
+ <Typography level='body-md' alignSelf={'center'}>
+ Welcome back,{' '}
+ {userProfile?.displayName || userProfile?.username}
+ </Typography>
+
+ <Button
+ fullWidth
+ size='lg'
+ sx={{ mt: 3, mb: 2 }}
+ onClick={() => {
+ getUserProfileAndNavigateToHome()
+ }}
+ >
+ Continue as {userProfile.displayName || userProfile.username}
+ </Button>
+ <Button
+ type='submit'
+ fullWidth
+ size='lg'
+ q
+ variant='plain'
+ sx={{
+ width: '100%',
+ mb: 2,
+ border: 'moccasin',
+ borderRadius: '8px',
+ }}
+ onClick={() => {
+ setUserProfile(null)
+ localStorage.removeItem('ca_token')
+ localStorage.removeItem('ca_expiration')
+ // go to login page:
+ window.location.href = '/login'
+ }}
+ >
+ Logout
+ </Button>
+ </>
+ )}
+ {!userProfile && (
+ <>
+ <Typography level='body2'>
+ Sign in to your account to continue
+ </Typography>
+ <Typography level='body2' alignSelf={'start'} mt={4}>
+ Username
+ </Typography>
+ <Input
+ margin='normal'
+ required
+ fullWidth
+ id='email'
+ label='Email Address'
+ name='email'
+ autoComplete='email'
+ autoFocus
+ value={username}
+ onChange={e => {
+ setUsername(e.target.value)
+ }}
+ />
+ <Typography level='body2' alignSelf={'start'}>
+ Password:
+ </Typography>
+ <Input
+ margin='normal'
+ required
+ fullWidth
+ name='password'
+ label='Password'
+ type='password'
+ id='password'
+ value={password}
+ onChange={e => {
+ setPassword(e.target.value)
+ }}
+ />
+
+ <Button
+ type='submit'
+ fullWidth
+ size='lg'
+ variant='solid'
+ sx={{
+ width: '100%',
+ mt: 3,
+ mb: 2,
+ border: 'moccasin',
+ borderRadius: '8px',
+ }}
+ onClick={handleSubmit}
+ >
+ Sign In
+ </Button>
+ <Button
+ type='submit'
+ fullWidth
+ size='lg'
+ q
+ variant='plain'
+ sx={{
+ width: '100%',
+ mb: 2,
+ border: 'moccasin',
+ borderRadius: '8px',
+ }}
+ onClick={handleForgotPassword}
+ >
+ Forgot password?
+ </Button>
+ </>
+ )}
+ <Divider> or </Divider>
+
+ <Box sx={{ width: '100%' }}>
+ <LoginSocialGoogle
+ client_id={GOOGLE_CLIENT_ID}
+ redirect_uri={REDIRECT_URL}
+ scope='openid profile email'
+ discoveryDocs='claims_supported'
+ access_type='online'
+ isOnlyGetToken={true}
+ onResolve={({ provider, data }) => {
+ loggedWithProvider(provider, data)
+ }}
+ onReject={err => {
+ setError("Couldn't log in with Google, please try again")
+ }}
+ >
+ <Button
+ variant='soft'
+ color='neutral'
+ size='lg'
+ fullWidth
+ sx={{
+ width: '100%',
+ mt: 1,
+ mb: 1,
+ border: 'moccasin',
+ borderRadius: '8px',
+ }}
+ >
+ <div className='flex gap-2'>
+ <GoogleIcon />
+ Continue with Google
+ </div>
+ </Button>
+ </LoginSocialGoogle>
+ </Box>
+
+ <Button
+ onClick={() => {
+ Navigate('/signup')
+ }}
+ fullWidth
+ variant='soft'
+ size='lg'
+ // sx={{ mt: 3, mb: 2 }}
+ >
+ Create new account
+ </Button>
+ </Sheet>
+ </Box>
+ <Snackbar
+ open={error !== null}
+ onClose={() => setError(null)}
+ autoHideDuration={3000}
+ message={error}
+ >
+ {error}
+ </Snackbar>
+ </Container>
+ )
+}
+
+export default LoginView
diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx
new file mode 100644
index 0000000..d83411f
--- /dev/null
+++ b/src/views/Authorization/Signup.jsx
@@ -0,0 +1,243 @@
+import {
+ Box,
+ Button,
+ Container,
+ Divider,
+ FormControl,
+ FormHelperText,
+ Input,
+ Sheet,
+ Typography,
+} from '@mui/joy'
+import React from 'react'
+import { useNavigate } from 'react-router-dom'
+import Logo from '../../Logo'
+import { login, signUp } from '../../utils/Fetcher'
+
+const SignupView = () => {
+ const [username, setUsername] = React.useState('')
+ const [password, setPassword] = React.useState('')
+ const Navigate = useNavigate()
+ const [displayName, setDisplayName] = React.useState('')
+ const [email, setEmail] = React.useState('')
+ const [usernameError, setUsernameError] = React.useState('')
+ const [passwordError, setPasswordError] = React.useState('')
+ const [emailError, setEmailError] = React.useState('')
+ const [displayNameError, setDisplayNameError] = React.useState('')
+ const [error, setError] = React.useState(null)
+ const handleLogin = (username, password) => {
+ login(username, password).then(response => {
+ if (response.status === 200) {
+ response.json().then(res => {
+ localStorage.setItem('ca_token', res.token)
+ localStorage.setItem('ca_expiration', res.expire)
+ setTimeout(() => {
+ // TODO: not sure if there is a race condition here
+ // but on first sign up it renavigates to login.
+ Navigate('/my/chores')
+ }, 500)
+ })
+ } else {
+ console.log('Login failed', response)
+ // Navigate('/login')
+ }
+ })
+ }
+ const handleSignUpValidation = () => {
+ // Reset errors before validation
+ setUsernameError(null)
+ setPasswordError(null)
+ setDisplayNameError(null)
+ setEmailError(null)
+
+ let isValid = true
+
+ if (!username.trim()) {
+ setUsernameError('Username is required')
+ isValid = false
+ }
+ if (username.length < 4) {
+ setUsernameError('Username must be at least 4 characters')
+ isValid = false
+ }
+ // if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ // setEmailError('Invalid email address')
+ // isValid = false
+ // }
+
+ if (password.length < 8) {
+ setPasswordError('Password must be at least 8 characters')
+ isValid = false
+ }
+
+ if (!displayName.trim()) {
+ setDisplayNameError('Display name is required')
+ isValid = false
+ }
+
+ // display name should only contain letters and spaces and numbers:
+ if (!/^[a-zA-Z0-9 ]+$/.test(displayName)) {
+ setDisplayNameError('Display name can only contain letters and numbers')
+ isValid = false
+ }
+
+ // username should only contain letters , numbers , dot and dash:
+ if (!/^[a-zA-Z0-9.-]+$/.test(username)) {
+ setUsernameError(
+ 'Username can only contain letters, numbers, dot and dash',
+ )
+ isValid = false
+ }
+
+ return isValid
+ }
+ const handleSubmit = async e => {
+ e.preventDefault()
+ if (!handleSignUpValidation()) {
+ return
+ }
+ signUp(username, password, displayName, email).then(response => {
+ if (response.status === 201) {
+ handleLogin(username, password)
+ } else {
+ console.log('Signup failed')
+ setError('Signup failed')
+ }
+ })
+ }
+
+ return (
+ <Container component='main' maxWidth='xs'>
+ <Box
+ sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ marginTop: 4,
+ }}
+ >
+ <Sheet
+ component='form'
+ sx={{
+ mt: 1,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ // alignItems: 'center',
+ padding: 2,
+ borderRadius: '8px',
+ boxShadow: 'md',
+ }}
+ >
+ <Box
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ flexDirection: 'column',
+ }}
+ >
+ <Logo />
+ <Typography level='h2'>
+ Done
+ <span
+ style={{
+ color: '#06b6d4',
+ }}
+ >
+ tick
+ </span>
+ </Typography>
+ <Typography level='body2'>
+ Create an account to get started!
+ </Typography>
+ </Box>
+ <Typography level='body2' alignSelf={'start'} mt={4}>
+ Username
+ </Typography>
+ <Input
+ margin='normal'
+ required
+ fullWidth
+ id='email'
+ label='Email Address'
+ name='email'
+ autoComplete='email'
+ autoFocus
+ value={username}
+ onChange={e => {
+ setUsernameError(null)
+ setUsername(e.target.value.trim())
+ }}
+ />
+ <FormControl error={usernameError}>
+ <FormHelperText c>{usernameError}</FormHelperText>
+ </FormControl>
+ {/* Error message display */}
+ <Typography level='body2' alignSelf={'start'}>
+ Password:
+ </Typography>
+ <Input
+ margin='normal'
+ required
+ fullWidth
+ name='password'
+ label='Password'
+ type='password'
+ id='password'
+ value={password}
+ onChange={e => {
+ setPasswordError(null)
+ setPassword(e.target.value)
+ }}
+ />
+ <FormControl error={passwordError}>
+ <FormHelperText>{passwordError}</FormHelperText>
+ </FormControl>
+ <Typography level='body2' alignSelf={'start'}>
+ Display Name:
+ </Typography>
+ <Input
+ margin='normal'
+ required
+ fullWidth
+ name='displayName'
+ label='Display Name'
+ id='displayName'
+ value={displayName}
+ onChange={e => {
+ setDisplayNameError(null)
+ setDisplayName(e.target.value)
+ }}
+ />
+ <FormControl error={displayNameError}>
+ <FormHelperText>{displayNameError}</FormHelperText>
+ </FormControl>
+ <Button
+ // type='submit'
+ size='lg'
+ fullWidth
+ variant='solid'
+ sx={{ mt: 3, mb: 1 }}
+ onClick={handleSubmit}
+ >
+ Sign Up
+ </Button>
+ <Divider> or </Divider>
+ <Button
+ size='lg'
+ onClick={() => {
+ Navigate('/login')
+ }}
+ fullWidth
+ variant='soft'
+ // sx={{ mt: 3, mb: 2 }}
+ >
+ Login
+ </Button>
+ </Sheet>
+ </Box>
+ </Container>
+ )
+}
+
+export default SignupView
diff --git a/src/views/Authorization/UpdatePasswordView.jsx b/src/views/Authorization/UpdatePasswordView.jsx
new file mode 100644
index 0000000..7177f2f
--- /dev/null
+++ b/src/views/Authorization/UpdatePasswordView.jsx
@@ -0,0 +1,194 @@
+// create boilerplate for ResetPasswordView:
+import {
+ Box,
+ Button,
+ Container,
+ FormControl,
+ FormHelperText,
+ Input,
+ Sheet,
+ Snackbar,
+ Typography,
+} from '@mui/joy'
+import { useState } from 'react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+
+import { API_URL } from '../../Config'
+import Logo from '../../Logo'
+
+const UpdatePasswordView = () => {
+ const navigate = useNavigate()
+ const [password, setPassword] = useState('')
+ const [passwordConfirm, setPasswordConfirm] = useState('')
+ const [passwordError, setPasswordError] = useState(null)
+ const [passworConfirmationError, setPasswordConfirmationError] =
+ useState(null)
+ const [searchParams] = useSearchParams()
+
+ const [updateStatusOk, setUpdateStatusOk] = useState(null)
+
+ const verifiticationCode = searchParams.get('c')
+
+ const handlePasswordChange = e => {
+ const password = e.target.value
+ setPassword(password)
+ if (password.length < 8) {
+ setPasswordError('Password must be at least 8 characters')
+ } else {
+ setPasswordError(null)
+ }
+ }
+ const handlePasswordConfirmChange = e => {
+ setPasswordConfirm(e.target.value)
+ if (e.target.value !== password) {
+ setPasswordConfirmationError('Passwords do not match')
+ } else {
+ setPasswordConfirmationError(null)
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (passwordError != null || passworConfirmationError != null) {
+ return
+ }
+ try {
+ const response = await fetch(
+ `${API_URL}/auth/password?c=${verifiticationCode}`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ password: password }),
+ },
+ )
+
+ if (response.ok) {
+ setUpdateStatusOk(true)
+ // wait 3 seconds and then redirect to login:
+ setTimeout(() => {
+ navigate('/login')
+ }, 3000)
+ } else {
+ setUpdateStatusOk(false)
+ }
+ } catch (error) {
+ setUpdateStatusOk(false)
+ }
+ }
+ return (
+ <Container component='main' maxWidth='xs'>
+ <Box
+ sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ marginTop: 4,
+ }}
+ >
+ <Sheet
+ component='form'
+ sx={{
+ mt: 1,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ // alignItems: 'center',
+ padding: 2,
+ borderRadius: '8px',
+ boxShadow: 'md',
+ }}
+ >
+ <Box
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ flexDirection: 'column',
+ }}
+ >
+ <Logo />
+ <Typography level='h2'>
+ Done
+ <span
+ style={{
+ color: '#06b6d4',
+ }}
+ >
+ tick
+ </span>
+ </Typography>
+ <Typography level='body2' mb={4}>
+ Please enter your new password below
+ </Typography>
+ </Box>
+
+ <FormControl error>
+ <Input
+ placeholder='Password'
+ type='password'
+ value={password}
+ onChange={handlePasswordChange}
+ error={passwordError !== null}
+ // onKeyDown={e => {
+ // if (e.key === 'Enter' && validateForm(validateFormInput)) {
+ // handleSubmit(e)
+ // }
+ // }}
+ />
+ <FormHelperText>{passwordError}</FormHelperText>
+ </FormControl>
+
+ <FormControl error>
+ <Input
+ placeholder='Confirm Password'
+ type='password'
+ value={passwordConfirm}
+ onChange={handlePasswordConfirmChange}
+ error={passworConfirmationError !== null}
+ // onKeyDown={e => {
+ // if (e.key === 'Enter' && validateForm(validateFormInput)) {
+ // handleSubmit(e)
+ // }
+ // }}
+ />
+ <FormHelperText>{passworConfirmationError}</FormHelperText>
+ </FormControl>
+ {/* helper to show password not matching : */}
+
+ <Button
+ fullWidth
+ size='lg'
+ sx={{
+ mt: 5,
+ mb: 1,
+ }}
+ onClick={handleSubmit}
+ >
+ Save Password
+ </Button>
+ <Button
+ fullWidth
+ size='lg'
+ variant='soft'
+ onClick={() => {
+ navigate('/login')
+ }}
+ >
+ Cancel
+ </Button>
+ </Sheet>
+ </Box>
+ <Snackbar
+ open={updateStatusOk !== true}
+ autoHideDuration={6000}
+ onClose={() => {
+ setUpdateStatusOk(null)
+ }}
+ >
+ Password update failed, try again later
+ </Snackbar>
+ </Container>
+ )
+}
+
+export default UpdatePasswordView
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
new file mode 100644
index 0000000..e8eb17d
--- /dev/null
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -0,0 +1,744 @@
+import {
+ Box,
+ Button,
+ Card,
+ Checkbox,
+ Chip,
+ Container,
+ Divider,
+ FormControl,
+ FormHelperText,
+ Input,
+ List,
+ ListItem,
+ Option,
+ Radio,
+ RadioGroup,
+ Select,
+ Sheet,
+ Typography,
+} from '@mui/joy'
+import moment from 'moment'
+import { useContext, useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { UserContext } from '../../contexts/UserContext'
+import {
+ CreateChore,
+ DeleteChore,
+ GetAllCircleMembers,
+ GetChoreByID,
+ GetChoreHistory,
+ GetThings,
+ SaveChore,
+} from '../../utils/Fetcher'
+import { isPlusAccount } from '../../utils/Helpers'
+import FreeSoloCreateOption from '../components/AutocompleteSelect'
+import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
+import RepeatSection from './RepeatSection'
+const ASSIGN_STRATEGIES = [
+ 'random',
+ 'least_assigned',
+ 'least_completed',
+ 'keep_last_assigned',
+]
+const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
+
+const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once']
+const NO_DUE_DATE_ALLOWED_TYPE = ['trigger']
+const ChoreEdit = () => {
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ const [chore, setChore] = useState([])
+ const [choresHistory, setChoresHistory] = useState([])
+ const [userHistory, setUserHistory] = useState({})
+ const { choreId } = useParams()
+ const [name, setName] = useState('')
+ const [confirmModelConfig, setConfirmModelConfig] = useState({})
+ const [assignees, setAssignees] = useState([])
+ const [performers, setPerformers] = useState([])
+ const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
+ const [dueDate, setDueDate] = useState(null)
+ const [completed, setCompleted] = useState(false)
+ const [completedDate, setCompletedDate] = useState('')
+ const [assignedTo, setAssignedTo] = useState(-1)
+ const [frequencyType, setFrequencyType] = useState('once')
+ const [frequency, setFrequency] = useState(1)
+ const [frequencyMetadata, setFrequencyMetadata] = useState({})
+ const [labels, setLabels] = useState([])
+ const [allUserThings, setAllUserThings] = useState([])
+ const [thingTrigger, setThingTrigger] = useState({})
+ const [isThingValid, setIsThingValid] = useState(false)
+
+ const [notificationMetadata, setNotificationMetadata] = useState({})
+
+ const [isRolling, setIsRolling] = useState(false)
+ const [isNotificable, setIsNotificable] = useState(false)
+ const [isActive, setIsActive] = useState(true)
+ const [updatedBy, setUpdatedBy] = useState(0)
+ const [createdBy, setCreatedBy] = useState(0)
+ const [errors, setErrors] = useState({})
+ const [attemptToSave, setAttemptToSave] = useState(false)
+
+ const Navigate = useNavigate()
+
+ const HandleValidateChore = () => {
+ const errors = {}
+
+ if (name.trim() === '') {
+ errors.name = 'Name is required'
+ }
+ if (assignees.length === 0) {
+ errors.assignees = 'At least 1 assignees is required'
+ }
+ if (assignedTo < 0) {
+ errors.assignedTo = 'Assigned to is required'
+ }
+ if (frequencyType === 'interval' && frequency < 1) {
+ errors.frequency = 'Frequency is required'
+ }
+ if (
+ frequencyType === 'days_of_the_week' &&
+ frequencyMetadata['days']?.length === 0
+ ) {
+ errors.frequency = 'At least 1 day is required'
+ }
+ if (
+ frequencyType === 'day_of_the_month' &&
+ frequencyMetadata['months']?.length === 0
+ ) {
+ errors.frequency = 'At least 1 month is required'
+ }
+ if (
+ dueDate === null &&
+ !NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) &&
+ !NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)
+ ) {
+ if (REPEAT_ON_TYPE.includes(frequencyType)) {
+ errors.dueDate = 'Start date is required'
+ } else {
+ errors.dueDate = 'Due date is required'
+ }
+ }
+ if (frequencyType === 'trigger') {
+ if (!isThingValid) {
+ errors.thingTrigger = 'Thing trigger is invalid'
+ }
+ }
+
+ // if there is any error then return false:
+ setErrors(errors)
+ if (Object.keys(errors).length > 0) {
+ return false
+ }
+ return true
+ }
+
+ const HandleSaveChore = () => {
+ setAttemptToSave(true)
+ if (!HandleValidateChore()) {
+ console.log('validation failed')
+ console.log(errors)
+ return
+ }
+ const chore = {
+ id: Number(choreId),
+ name: name,
+ assignees: assignees,
+ dueDate: dueDate ? new Date(dueDate).toISOString() : null,
+ frequencyType: frequencyType,
+ frequency: Number(frequency),
+ frequencyMetadata: frequencyMetadata,
+ assignedTo: assignedTo,
+ assignStrategy: assignStrategy,
+ isRolling: isRolling,
+ isActive: isActive,
+ notification: isNotificable,
+ labels: labels,
+ notificationMetadata: notificationMetadata,
+ thingTrigger: thingTrigger,
+ }
+ let SaveFunction = CreateChore
+ if (choreId > 0) {
+ SaveFunction = SaveChore
+ }
+
+ SaveFunction(chore).then(response => {
+ if (response.status === 200) {
+ Navigate(`/my/chores`)
+ } else {
+ alert('Failed to save chore')
+ }
+ })
+ }
+ useEffect(() => {
+ //fetch performers:
+ GetAllCircleMembers()
+ .then(response => response.json())
+ .then(data => {
+ setPerformers(data.res)
+ })
+ GetThings().then(response => {
+ response.json().then(data => {
+ setAllUserThings(data.res)
+ })
+ })
+ // fetch chores:
+ if (choreId > 0) {
+ GetChoreByID(choreId)
+ .then(response => {
+ if (response.status !== 200) {
+ alert('You are not authorized to view this chore.')
+ Navigate('/my/chores')
+ return null
+ } else {
+ return response.json()
+ }
+ })
+ .then(data => {
+ setChore(data.res)
+ setName(data.res.name ? data.res.name : '')
+ setAssignees(data.res.assignees ? data.res.assignees : [])
+ setAssignedTo(data.res.assignedTo)
+ setFrequencyType(
+ data.res.frequencyType ? data.res.frequencyType : 'once',
+ )
+
+ setFrequencyMetadata(JSON.parse(data.res.frequencyMetadata))
+ setFrequency(data.res.frequency)
+
+ setNotificationMetadata(JSON.parse(data.res.notificationMetadata))
+ setLabels(data.res.labels ? data.res.labels.split(',') : [])
+
+ setAssignStrategy(
+ data.res.assignStrategy
+ ? data.res.assignStrategy
+ : ASSIGN_STRATEGIES[2],
+ )
+ setIsRolling(data.res.isRolling)
+ setIsActive(data.res.isActive)
+ // parse the due date to a string from this format "2021-10-10T00:00:00.000Z"
+ // use moment.js or date-fns to format the date for to be usable in the input field:
+ setDueDate(
+ data.res.nextDueDate
+ ? moment(data.res.nextDueDate).format('YYYY-MM-DDTHH:mm:ss')
+ : null,
+ )
+ setUpdatedBy(data.res.updatedBy)
+ setCreatedBy(data.res.createdBy)
+ setIsNotificable(data.res.notification)
+ setThingTrigger(data.res.thingChore)
+ // setDueDate(data.res.dueDate)
+ // setCompleted(data.res.completed)
+ // setCompletedDate(data.res.completedDate)
+ })
+
+ // fetch chores history:
+ GetChoreHistory(choreId)
+ .then(response => response.json())
+ .then(data => {
+ setChoresHistory(data.res)
+ const newUserChoreHistory = {}
+ data.res.forEach(choreHistory => {
+ if (newUserChoreHistory[choreHistory.completedBy]) {
+ newUserChoreHistory[choreHistory.completedBy] += 1
+ } else {
+ newUserChoreHistory[choreHistory.completedBy] = 1
+ }
+ })
+
+ setUserHistory(newUserChoreHistory)
+ })
+ }
+ // set focus on the first input field:
+ else {
+ // new task/ chore set focus on the first input field:
+ document.querySelector('input').focus()
+ }
+ }, [])
+
+ useEffect(() => {
+ // if frequancy type change to somthing need a due date then set it to the current date:
+ if (!NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && !dueDate) {
+ setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00'))
+ }
+ if (NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)) {
+ setDueDate(null)
+ }
+ }, [frequencyType])
+
+ useEffect(() => {
+ if (assignees.length === 1) {
+ setAssignedTo(assignees[0].userId)
+ }
+ }, [assignees])
+
+ useEffect(() => {
+ if (performers.length > 0 && assignees.length === 0) {
+ setAssignees([
+ {
+ userId: userProfile.id,
+ },
+ ])
+ }
+ }, [performers])
+
+ // if user resolve the error trigger validation to remove the error message from the respective field
+ useEffect(() => {
+ if (attemptToSave) {
+ HandleValidateChore()
+ }
+ }, [assignees, name, frequencyMetadata, attemptToSave, dueDate])
+
+ const handleDelete = () => {
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Delete Chore',
+ confirmText: 'Delete',
+ cancelText: 'Cancel',
+ message: 'Are you sure you want to delete this chore?',
+ onClose: isConfirmed => {
+ if (isConfirmed === true) {
+ DeleteChore(choreId).then(response => {
+ if (response.status === 200) {
+ Navigate('/my/chores')
+ } else {
+ alert('Failed to delete chore')
+ }
+ })
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+ return (
+ <Container maxWidth='md'>
+ {/* <Typography level='h3' mb={1.5}>
+ Edit Chore
+ </Typography> */}
+ <Box>
+ <FormControl error={errors.name}>
+ <Typography level='h4'>Descritpion :</Typography>
+ <Typography level='h5'>What is this chore about?</Typography>
+ <Input value={name} onChange={e => setName(e.target.value)} />
+ <FormHelperText error>{errors.name}</FormHelperText>
+ </FormControl>
+ </Box>
+ <Box mt={2}>
+ <Typography level='h4'>Assignees :</Typography>
+ <Typography level='h5'>Who can do this chore?</Typography>
+ <Card>
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {performers?.map((item, index) => (
+ <ListItem key={item.id}>
+ <Checkbox
+ // disabled={index === 0}
+ checked={assignees.find(a => a.userId == item.id) != null}
+ onClick={() => {
+ if (assignees.find(a => a.userId == item.id)) {
+ setAssignees(assignees.filter(i => i.userId !== item.id))
+ } else {
+ setAssignees([...assignees, { userId: item.id }])
+ }
+ }}
+ overlay
+ disableIcon
+ variant='soft'
+ label={item.displayName}
+ />
+ </ListItem>
+ ))}
+ </List>
+ </Card>
+ <FormControl error={Boolean(errors.assignee)}>
+ <FormHelperText error>{Boolean(errors.assignee)}</FormHelperText>
+ </FormControl>
+ </Box>
+ {assignees.length > 1 && (
+ // this wrap the details that needed if we have more than one assingee
+ // we need to pick the next assignedTo and also the strategy to pick the next assignee.
+ // if we have only one then no need to display this section
+ <>
+ <Box mt={2}>
+ <Typography level='h4'>Assigned :</Typography>
+ <Typography level='h5'>
+ Who is assigned the next due chore?
+ </Typography>
+
+ <Select
+ placeholder={
+ assignees.length === 0
+ ? 'No Assignees yet can perform this chore'
+ : 'Select an assignee for this chore'
+ }
+ disabled={assignees.length === 0}
+ value={assignedTo > -1 ? assignedTo : null}
+ >
+ {performers
+ ?.filter(p => assignees.find(a => a.userId == p.userId))
+ .map((item, index) => (
+ <Option
+ value={item.id}
+ key={item.displayName}
+ onClick={() => {
+ setAssignedTo(item.id)
+ }}
+ >
+ {item.displayName}
+ {/* <Chip size='sm' color='neutral' variant='soft'>
+ </Chip> */}
+ </Option>
+ ))}
+ </Select>
+ </Box>
+ <Box mt={2}>
+ <Typography level='h4'>Picking Mode :</Typography>
+ <Typography level='h5'>
+ How to pick the next assignee for the following chore?
+ </Typography>
+
+ <Card>
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {ASSIGN_STRATEGIES.map((item, idx) => (
+ <ListItem key={item}>
+ <Checkbox
+ // disabled={index === 0}
+ checked={assignStrategy === item}
+ onClick={() => setAssignStrategy(item)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={item
+ .split('_')
+ .map(x => x.charAt(0).toUpperCase() + x.slice(1))
+ .join(' ')}
+ />
+ </ListItem>
+ ))}
+ </List>
+ </Card>
+ </Box>
+ </>
+ )}
+ <RepeatSection
+ frequency={frequency}
+ onFrequencyUpdate={setFrequency}
+ frequencyType={frequencyType}
+ onFrequencyTypeUpdate={setFrequencyType}
+ frequencyMetadata={frequencyMetadata}
+ onFrequencyMetadataUpdate={setFrequencyMetadata}
+ frequencyError={errors?.frequency}
+ allUserThings={allUserThings}
+ onTriggerUpdate={thingUpdate => {
+ if (thingUpdate === null) {
+ setThingTrigger(null)
+ return
+ }
+ setThingTrigger({
+ triggerState: thingUpdate.triggerState,
+ condition: thingUpdate.condition,
+ thingID: thingUpdate.thing.id,
+ })
+ }}
+ OnTriggerValidate={setIsThingValid}
+ isAttemptToSave={attemptToSave}
+ selectedThing={thingTrigger}
+ />
+
+ <Box mt={2}>
+ <Typography level='h4'>
+ {REPEAT_ON_TYPE.includes(frequencyType) ? 'Start date' : 'Due date'} :
+ </Typography>
+ {frequencyType === 'trigger' && !dueDate && (
+ <Typography level='body-sm'>
+ Due Date will be set when the trigger of the thing is met
+ </Typography>
+ )}
+
+ {NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && (
+ <FormControl sx={{ mt: 1 }}>
+ <Checkbox
+ onChange={e => {
+ if (e.target.checked) {
+ setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00'))
+ } else {
+ setDueDate(null)
+ }
+ }}
+ defaultChecked={dueDate !== null}
+ checked={dueDate !== null}
+ value={dueDate !== null}
+ overlay
+ label='Give this task a due date'
+ />
+ <FormHelperText>
+ task needs to be completed by a specific time.
+ </FormHelperText>
+ </FormControl>
+ )}
+ {dueDate && (
+ <FormControl error={Boolean(errors.dueDate)}>
+ <Typography level='h5'>
+ {REPEAT_ON_TYPE.includes(frequencyType)
+ ? 'When does this chore start?'
+ : 'When is the next first time this chore is due?'}
+ </Typography>
+ <Input
+ type='datetime-local'
+ value={dueDate}
+ onChange={e => {
+ setDueDate(e.target.value)
+ }}
+ />
+ <FormHelperText>{errors.dueDate}</FormHelperText>
+ </FormControl>
+ )}
+ </Box>
+ {!['once', 'no_repeat'].includes(frequencyType) && (
+ <Box mt={2}>
+ <Typography level='h4'>Scheduling Preferences: </Typography>
+ <Typography level='h5'>
+ How to reschedule the next due date?
+ </Typography>
+
+ <RadioGroup name='tiers' sx={{ gap: 1, '& > div': { p: 1 } }}>
+ <FormControl>
+ <Radio
+ overlay
+ checked={!isRolling}
+ onClick={() => setIsRolling(false)}
+ label='Reschedule from due date'
+ />
+ <FormHelperText>
+ the next task will be scheduled from the original due date, even
+ if the previous task was completed late
+ </FormHelperText>
+ </FormControl>
+ <FormControl>
+ <Radio
+ overlay
+ checked={isRolling}
+ onClick={() => setIsRolling(true)}
+ label='Reschedule from completion date'
+ />
+ <FormHelperText>
+ the next task will be scheduled from the actual completion date
+ of the previous task
+ </FormHelperText>
+ </FormControl>
+ </RadioGroup>
+ </Box>
+ )}
+ <Box mt={2}>
+ <Typography level='h4'>Notifications : </Typography>
+ <Typography level='h5'>
+ Get Reminders when this task is due or completed
+ {!isPlusAccount(userProfile) && (
+ <Chip variant='soft' color='warning'>
+ Not available in Basic Plan
+ </Chip>
+ )}
+ </Typography>
+
+ <FormControl sx={{ mt: 1 }}>
+ <Checkbox
+ onChange={e => {
+ setIsNotificable(e.target.checked)
+ }}
+ defaultChecked={isNotificable}
+ checked={isNotificable}
+ value={isNotificable}
+ disabled={!isPlusAccount(userProfile)}
+ overlay
+ label='Notify for this task'
+ />
+ <FormHelperText
+ sx={{
+ opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
+ }}
+ >
+ Receive notifications for this task
+ </FormHelperText>
+ </FormControl>
+ </Box>
+ {isNotificable && (
+ <Box
+ ml={4}
+ sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 2,
+
+ '& > div': { p: 2, borderRadius: 'md', display: 'flex' },
+ }}
+ >
+ <Card variant='outlined'>
+ <Typography level='h5'>
+ What things should trigger the notification?
+ </Typography>
+ {[
+ {
+ title: 'Due Date/Time',
+ description: 'A simple reminder that a task is due',
+ id: 'dueDate',
+ },
+ // {
+ // title: 'Upon Completion',
+ // description: 'A notification when a task is completed',
+ // id: 'completion',
+ // },
+ {
+ title: 'Predued',
+ description: 'before a task is due in few hours',
+ id: 'predue',
+ },
+ {
+ title: 'Overdue',
+ description: 'A notification when a task is overdue',
+ },
+ {
+ title: 'Nagging',
+ description: 'Daily reminders until the task is completed',
+ id: 'nagging',
+ },
+ ].map(item => (
+ <FormControl sx={{ mb: 1 }} key={item.id}>
+ <Checkbox
+ overlay
+ onClick={() => {
+ setNotificationMetadata({
+ ...notificationMetadata,
+ [item.id]: !notificationMetadata[item.id],
+ })
+ }}
+ checked={
+ notificationMetadata ? notificationMetadata[item.id] : false
+ }
+ label={item.title}
+ key={item.title}
+ />
+ <FormHelperText>{item.description}</FormHelperText>
+ </FormControl>
+ ))}
+ </Card>
+ </Box>
+ )}
+ <Box mt={2}>
+ <Typography level='h4'>Labels :</Typography>
+ <Typography level='h5'>
+ Things to remember about this chore or to tag it
+ </Typography>
+ <FreeSoloCreateOption
+ options={labels}
+ onSelectChange={changes => {
+ const newLabels = []
+ changes.map(change => {
+ // if type is string :
+ if (typeof change === 'string') {
+ // add the change to the labels array:
+ newLabels.push(change)
+ } else {
+ newLabels.push(change.inputValue)
+ }
+ })
+ setLabels(newLabels)
+ }}
+ />
+ </Box>
+ {choreId > 0 && (
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
+ <Sheet
+ sx={{
+ p: 2,
+ borderRadius: 'md',
+ boxShadow: 'sm',
+ }}
+ >
+ <Typography level='body1'>
+ Created by{' '}
+ <Chip variant='solid'>
+ {performers.find(f => f.id === createdBy)?.displayName}
+ </Chip>{' '}
+ {moment(chore.createdAt).fromNow()}
+ </Typography>
+ {(chore.updatedAt && updatedBy > 0 && (
+ <>
+ <Divider sx={{ my: 1 }} />
+
+ <Typography level='body1'>
+ Updated by{' '}
+ <Chip variant='solid'>
+ {performers.find(f => f.id === updatedBy)?.displayName}
+ </Chip>{' '}
+ {moment(chore.updatedAt).fromNow()}
+ </Typography>
+ </>
+ )) || <></>}
+ </Sheet>
+ </Box>
+ )}
+ <Divider sx={{ mb: 9 }} />
+
+ {/* <Box mt={2} alignSelf={'flex-start'} display='flex' gap={2}>
+ <Button onClick={SaveChore}>Save</Button>
+ </Box> */}
+ <Sheet
+ variant='outlined'
+ sx={{
+ position: 'fixed',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ p: 2, // padding
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: 2,
+ 'z-index': 1000,
+ bgcolor: 'background.body',
+ boxShadow: 'md', // Add a subtle shadow
+ }}
+ >
+ {choreId > 0 && (
+ <Button
+ color='danger'
+ variant='solid'
+ onClick={() => {
+ // confirm before deleting:
+ handleDelete()
+ }}
+ >
+ Delete
+ </Button>
+ )}
+ <Button
+ color='neutral'
+ variant='outlined'
+ onClick={() => {
+ window.history.back()
+ }}
+ >
+ Cancel
+ </Button>
+ <Button color='primary' variant='solid' onClick={HandleSaveChore}>
+ {choreId > 0 ? 'Save' : 'Create'}
+ </Button>
+ </Sheet>
+ <ConfirmationModal config={confirmModelConfig} />
+ {/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */}
+ </Container>
+ )
+}
+
+export default ChoreEdit
diff --git a/src/views/ChoreEdit/RepeatSection.jsx b/src/views/ChoreEdit/RepeatSection.jsx
new file mode 100644
index 0000000..cb680eb
--- /dev/null
+++ b/src/views/ChoreEdit/RepeatSection.jsx
@@ -0,0 +1,496 @@
+import {
+ Box,
+ Card,
+ Checkbox,
+ Chip,
+ FormControl,
+ FormHelperText,
+ Grid,
+ Input,
+ List,
+ ListItem,
+ Option,
+ Radio,
+ RadioGroup,
+ Select,
+ Typography,
+} from '@mui/joy'
+import { useContext, useState } from 'react'
+import { UserContext } from '../../contexts/UserContext'
+import { isPlusAccount } from '../../utils/Helpers'
+import ThingTriggerSection from './ThingTriggerSection'
+
+const FREQUANCY_TYPES_RADIOS = [
+ 'daily',
+ 'weekly',
+ 'monthly',
+ 'yearly',
+ 'adaptive',
+ 'custom',
+]
+
+const FREQUENCY_TYPE_MESSAGE = {
+ adaptive:
+ 'This chore will be scheduled dynamically based on previous completion dates.',
+ custom: 'This chore will be scheduled based on a custom frequency.',
+}
+const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
+const FREQUANCY_TYPES = [
+ 'once',
+ 'daily',
+ 'weekly',
+ 'monthly',
+ 'yearly',
+ 'adaptive',
+ ...REPEAT_ON_TYPE,
+]
+const MONTH_WITH_NO_31_DAYS = [
+ // TODO: Handle these months if day is 31
+ 'february',
+ 'april',
+ 'june',
+ 'september',
+ 'november',
+]
+const RepeatOnSections = ({
+ frequencyType,
+ frequency,
+ onFrequencyUpdate,
+ onFrequencyTypeUpdate,
+ frequencyMetadata,
+ onFrequencyMetadataUpdate,
+ things,
+}) => {
+ const [months, setMonths] = useState({})
+ // const [dayOftheMonth, setDayOftheMonth] = useState(1)
+ const [daysOfTheWeek, setDaysOfTheWeek] = useState({})
+ const [monthsOfTheYear, setMonthsOfTheYear] = useState({})
+ const [intervalUnit, setIntervalUnit] = useState('days')
+
+ switch (frequencyType) {
+ case 'interval':
+ return (
+ <Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
+ <Typography level='h5'>Every: </Typography>
+ <Input
+ type='number'
+ value={frequency}
+ onChange={e => {
+ if (e.target.value < 1) {
+ e.target.value = 1
+ }
+ onFrequencyUpdate(e.target.value)
+ }}
+ />
+ <Select placeholder='Unit' value={intervalUnit}>
+ {['hours', 'days', 'weeks', 'months', 'years'].map(item => (
+ <Option
+ key={item}
+ value={item}
+ onClick={() => {
+ setIntervalUnit(item)
+ onFrequencyMetadataUpdate({
+ unit: item,
+ })
+ }}
+ >
+ {item.charAt(0).toUpperCase() + item.slice(1)}
+ </Option>
+ ))}
+ </Select>
+ </Grid>
+ )
+ case 'days_of_the_week':
+ return (
+ <Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
+ <Card>
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {[
+ 'monday',
+ 'tuesday',
+ 'wednesday',
+ 'thursday',
+ 'friday',
+ 'saturday',
+ 'sunday',
+ ].map(item => (
+ <ListItem key={item}>
+ <Checkbox
+ // disabled={index === 0}
+
+ checked={frequencyMetadata?.days?.includes(item) || false}
+ onClick={() => {
+ const newDaysOfTheWeek = frequencyMetadata['days'] || []
+ if (newDaysOfTheWeek.includes(item)) {
+ newDaysOfTheWeek.splice(
+ newDaysOfTheWeek.indexOf(item),
+ 1,
+ )
+ } else {
+ newDaysOfTheWeek.push(item)
+ }
+
+ onFrequencyMetadataUpdate({
+ days: newDaysOfTheWeek.sort(),
+ })
+ }}
+ overlay
+ disableIcon
+ variant='soft'
+ label={item.charAt(0).toUpperCase() + item.slice(1)}
+ />
+ </ListItem>
+ ))}
+ </List>
+ </Card>
+ </Grid>
+ )
+ case 'day_of_the_month':
+ return (
+ <Grid
+ item
+ sm={12}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ }}
+ >
+ <Box
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ mb: 1.5,
+ }}
+ >
+ <Typography>on the </Typography>
+ <Input
+ sx={{ width: '80px' }}
+ type='number'
+ value={frequency}
+ onChange={e => {
+ if (e.target.value < 1) {
+ e.target.value = 1
+ } else if (e.target.value > 31) {
+ e.target.value = 31
+ }
+ // setDayOftheMonth(e.target.value)
+
+ onFrequencyUpdate(e.target.value)
+ }}
+ />
+ <Typography>of the following month/s: </Typography>
+ </Box>
+ <Card>
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {[
+ 'january',
+ 'february',
+ 'march',
+ 'april',
+ 'may',
+ 'june',
+ 'july',
+ 'august',
+ 'september',
+ 'october',
+ 'november',
+ 'december',
+ ].map(item => (
+ <ListItem key={item}>
+ <Checkbox
+ // disabled={index === 0}
+ checked={frequencyMetadata?.months?.includes(item)}
+ // checked={months[item] || false}
+ // onClick={() => {
+ // const newMonthsOfTheYear = {
+ // ...monthsOfTheYear,
+ // }
+ // newMonthsOfTheYear[item] = !newMonthsOfTheYear[item]
+ // onFrequencyMetadataUpdate({
+ // months: newMonthsOfTheYear,
+ // })
+ // setMonthsOfTheYear(newMonthsOfTheYear)
+ // }}
+ onClick={() => {
+ const newMonthsOfTheYear =
+ frequencyMetadata['months'] || []
+ if (newMonthsOfTheYear.includes(item)) {
+ newMonthsOfTheYear.splice(
+ newMonthsOfTheYear.indexOf(item),
+ 1,
+ )
+ } else {
+ newMonthsOfTheYear.push(item)
+ }
+
+ onFrequencyMetadataUpdate({
+ months: newMonthsOfTheYear.sort(),
+ })
+ console.log('newMonthsOfTheYear', newMonthsOfTheYear)
+ // setDaysOfTheWeek(newDaysOfTheWeek)
+ }}
+ overlay
+ disableIcon
+ variant='soft'
+ label={item.charAt(0).toUpperCase() + item.slice(1)}
+ />
+ </ListItem>
+ ))}
+ </List>
+ </Card>
+ </Grid>
+ )
+
+ default:
+ return <></>
+ }
+}
+
+const RepeatSection = ({
+ frequencyType,
+ frequency,
+ onFrequencyUpdate,
+ onFrequencyTypeUpdate,
+ frequencyMetadata,
+ onFrequencyMetadataUpdate,
+ frequencyError,
+ allUserThings,
+ onTriggerUpdate,
+ OnTriggerValidate,
+ isAttemptToSave,
+ selectedThing,
+}) => {
+ const [repeatOn, setRepeatOn] = useState('interval')
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ return (
+ <Box mt={2}>
+ <Typography level='h4'>Repeat :</Typography>
+ <FormControl sx={{ mt: 1 }}>
+ <Checkbox
+ onChange={e => {
+ onFrequencyTypeUpdate(e.target.checked ? 'daily' : 'once')
+ if (e.target.checked) {
+ onTriggerUpdate(null)
+ }
+ }}
+ defaultChecked={!['once', 'trigger'].includes(frequencyType)}
+ checked={!['once', 'trigger'].includes(frequencyType)}
+ value={!['once', 'trigger'].includes(frequencyType)}
+ overlay
+ label='Repeat this task'
+ />
+ <FormHelperText>
+ Is this something needed to be done regularly?
+ </FormHelperText>
+ </FormControl>
+ {!['once', 'trigger'].includes(frequencyType) && (
+ <>
+ <Card sx={{ mt: 1 }}>
+ <Typography level='h5'>How often should it be repeated?</Typography>
+
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {FREQUANCY_TYPES_RADIOS.map((item, index) => (
+ <ListItem key={item}>
+ <Checkbox
+ // disabled={index === 0}
+ checked={
+ item === frequencyType ||
+ (item === 'custom' &&
+ REPEAT_ON_TYPE.includes(frequencyType))
+ }
+ // defaultChecked={item === frequencyType}
+ onClick={() => {
+ if (item === 'custom') {
+ onFrequencyTypeUpdate(REPEAT_ON_TYPE[0])
+ onFrequencyUpdate(1)
+ onFrequencyMetadataUpdate({
+ unit: 'days',
+ })
+ return
+ }
+ onFrequencyTypeUpdate(item)
+ }}
+ overlay
+ disableIcon
+ variant='soft'
+ label={
+ item.charAt(0).toUpperCase() +
+ item.slice(1).replace('_', ' ')
+ }
+ />
+ </ListItem>
+ ))}
+ </List>
+ <Typography>{FREQUENCY_TYPE_MESSAGE[frequencyType]}</Typography>
+ {frequencyType === 'custom' ||
+ (REPEAT_ON_TYPE.includes(frequencyType) && (
+ <>
+ <Grid container spacing={1} mt={2}>
+ <Grid item>
+ <Typography>Repeat on:</Typography>
+ <Box
+ sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
+ >
+ <RadioGroup
+ orientation='horizontal'
+ aria-labelledby='segmented-controls-example'
+ name='justify'
+ // value={justify}
+ // onChange={event => setJustify(event.target.value)}
+ sx={{
+ minHeight: 48,
+ padding: '4px',
+ borderRadius: '12px',
+ bgcolor: 'neutral.softBg',
+ '--RadioGroup-gap': '4px',
+ '--Radio-actionRadius': '8px',
+ mb: 1,
+ }}
+ >
+ {REPEAT_ON_TYPE.map(item => (
+ <Radio
+ key={item}
+ color='neutral'
+ checked={item === frequencyType}
+ onClick={() => {
+ if (
+ item === 'day_of_the_month' ||
+ item === 'interval'
+ ) {
+ onFrequencyUpdate(1)
+ }
+ onFrequencyTypeUpdate(item)
+ if (item === 'days_of_the_week') {
+ onFrequencyMetadataUpdate({ days: [] })
+ } else if (item === 'day_of_the_month') {
+ onFrequencyMetadataUpdate({ months: [] })
+ } else if (item === 'interval') {
+ onFrequencyMetadataUpdate({ unit: 'days' })
+ }
+ // setRepeatOn(item)
+ }}
+ value={item}
+ disableIcon
+ label={item
+ .split('_')
+ .map((i, idx) => {
+ // first or last word
+ if (
+ idx === 0 ||
+ idx === item.split('_').length - 1
+ ) {
+ return (
+ i.charAt(0).toUpperCase() + i.slice(1)
+ )
+ }
+ return i
+ })
+ .join(' ')}
+ variant='plain'
+ sx={{
+ px: 2,
+ alignItems: 'center',
+ }}
+ slotProps={{
+ action: ({ checked }) => ({
+ sx: {
+ ...(checked && {
+ bgcolor: 'background.surface',
+ boxShadow: 'sm',
+ '&:hover': {
+ bgcolor: 'background.surface',
+ },
+ }),
+ },
+ }),
+ }}
+ />
+ ))}
+ </RadioGroup>
+ </Box>
+ </Grid>
+
+ <RepeatOnSections
+ frequency={frequency}
+ onFrequencyUpdate={onFrequencyUpdate}
+ frequencyType={frequencyType}
+ onFrequencyTypeUpdate={onFrequencyTypeUpdate}
+ frequencyMetadata={frequencyMetadata || {}}
+ onFrequencyMetadataUpdate={onFrequencyMetadataUpdate}
+ things={allUserThings}
+ />
+ </Grid>
+ </>
+ ))}
+ <FormControl error={Boolean(frequencyError)}>
+ <FormHelperText error>{frequencyError}</FormHelperText>
+ </FormControl>
+ </Card>
+ </>
+ )}
+ <FormControl sx={{ mt: 1 }}>
+ <Checkbox
+ onChange={e => {
+ onFrequencyTypeUpdate(e.target.checked ? 'trigger' : 'once')
+ // if unchecked, set selectedThing to null:
+ if (!e.target.checked) {
+ onTriggerUpdate(null)
+ }
+ }}
+ defaultChecked={frequencyType === 'trigger'}
+ checked={frequencyType === 'trigger'}
+ value={frequencyType === 'trigger'}
+ disabled={!isPlusAccount(userProfile)}
+ overlay
+ label='Trigger this task based on a thing state'
+ />
+ <FormHelperText
+ sx={{
+ opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
+ }}
+ >
+ Is this something that should be done when a thing state changes?{' '}
+ {!isPlusAccount(userProfile) && (
+ <Chip variant='soft' color='warning'>
+ Not available in Basic Plan
+ </Chip>
+ )}
+ </FormHelperText>
+ </FormControl>
+ {frequencyType === 'trigger' && (
+ <ThingTriggerSection
+ things={allUserThings}
+ onTriggerUpdate={onTriggerUpdate}
+ onValidate={OnTriggerValidate}
+ isAttemptToSave={isAttemptToSave}
+ selected={selectedThing}
+ />
+ )}
+ </Box>
+ )
+}
+
+export default RepeatSection
diff --git a/src/views/ChoreEdit/ThingTriggerSection.jsx b/src/views/ChoreEdit/ThingTriggerSection.jsx
new file mode 100644
index 0000000..7a040ad
--- /dev/null
+++ b/src/views/ChoreEdit/ThingTriggerSection.jsx
@@ -0,0 +1,230 @@
+import { Widgets } from '@mui/icons-material'
+import {
+ Autocomplete,
+ Box,
+ Button,
+ Card,
+ Chip,
+ FormControl,
+ FormLabel,
+ Input,
+ ListItem,
+ ListItemContent,
+ ListItemDecorator,
+ Option,
+ Select,
+ TextField,
+ Typography,
+} from '@mui/joy'
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+const isValidTrigger = (thing, condition, triggerState) => {
+ const newErrors = {}
+ if (!thing || !triggerState) {
+ newErrors.thing = 'Please select a thing and trigger state'
+ return false
+ }
+ if (thing.type === 'boolean') {
+ if (['true', 'false'].includes(triggerState)) {
+ return true
+ } else {
+ newErrors.type = 'Boolean type does not require a condition'
+ return false
+ }
+ }
+ if (thing.type === 'number') {
+ if (isNaN(triggerState)) {
+ newErrors.triggerState = 'Trigger state must be a number'
+ return false
+ }
+ if (['eq', 'neq', 'gt', 'gte', 'lt', 'lte'].includes(condition)) {
+ return true
+ }
+ }
+ if (thing.type === 'text') {
+ if (typeof triggerState === 'string') {
+ return true
+ }
+ }
+ newErrors.triggerState = 'Trigger state must be a number'
+
+ return false
+}
+
+const ThingTriggerSection = ({
+ things,
+ onTriggerUpdate,
+ onValidate,
+ selected,
+ isAttepmtingToSave,
+}) => {
+ const [selectedThing, setSelectedThing] = useState(null)
+ const [condition, setCondition] = useState(null)
+ const [triggerState, setTriggerState] = useState(null)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (selected) {
+ setSelectedThing(things?.find(t => t.id === selected.thingId))
+ setCondition(selected.condition)
+ setTriggerState(selected.triggerState)
+ }
+ }, [things])
+
+ useEffect(() => {
+ if (selectedThing && triggerState) {
+ onTriggerUpdate({
+ thing: selectedThing,
+ condition: condition,
+ triggerState: triggerState,
+ })
+ }
+ if (isValidTrigger(selectedThing, condition, triggerState)) {
+ onValidate(true)
+ } else {
+ onValidate(false)
+ }
+ }, [selectedThing, condition, triggerState])
+
+ return (
+ <Card sx={{ mt: 1 }}>
+ <Typography level='h5'>
+ Trigger a task when a thing state changes to a desired state
+ </Typography>
+ {things.length !== 0 && (
+ <Typography level='body-sm'>
+ it's look like you don't have any things yet, create a thing to
+ trigger a task when the state changes.
+ <Button
+ startDecorator={<Widgets />}
+ size='sm'
+ onClick={() => {
+ navigate('/things')
+ }}
+ >
+ Go to Things
+ </Button>{' '}
+ to create a thing
+ </Typography>
+ )}
+ <FormControl error={isAttepmtingToSave && !selectedThing}>
+ <Autocomplete
+ options={things}
+ value={selectedThing}
+ onChange={(e, newValue) => setSelectedThing(newValue)}
+ getOptionLabel={option => option.name}
+ renderOption={(props, option) => (
+ <ListItem {...props}>
+ <Box
+ sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ p: 1,
+ }}
+ >
+ <ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
+ <Typography level='body-lg' textColor='primary'>
+ {option.name}
+ </Typography>
+ </ListItemDecorator>
+ <ListItemContent>
+ <Typography level='body2' textColor='text.secondary'>
+ <Chip>type: {option.type}</Chip>{' '}
+ <Chip>state: {option.state}</Chip>
+ </Typography>
+ </ListItemContent>
+ </Box>
+ </ListItem>
+ )}
+ renderInput={params => (
+ <TextField {...params} label='Select a thing' />
+ )}
+ />
+ </FormControl>
+ <Typography level='body-sm'>
+ Create a condition to trigger a task when the thing state changes to
+ desired state
+ </Typography>
+ {selectedThing?.type == 'boolean' && (
+ <Box>
+ <Typography level='body-sm'>
+ When the state of {selectedThing.name} changes as specified below,
+ the task will become due.
+ </Typography>
+ <Select
+ value={triggerState}
+ onChange={e => {
+ if (e?.target.value === 'true' || e?.target.value === 'false')
+ setTriggerState(e.target.value)
+ else setTriggerState('false')
+ }}
+ >
+ {['true', 'false'].map(state => (
+ <Option
+ key={state}
+ value={state}
+ onClick={() => setTriggerState(state)}
+ >
+ {state.charAt(0).toUpperCase() + state.slice(1)}
+ </Option>
+ ))}
+ </Select>
+ </Box>
+ )}
+ {selectedThing?.type == 'number' && (
+ <Box>
+ <Typography level='body-sm'>
+ When the state of {selectedThing.name} changes as specified below,
+ the task will become due.
+ </Typography>
+
+ <Box sx={{ display: 'flex', gap: 1, direction: 'row' }}>
+ <Typography level='body-sm'>State is</Typography>
+ <Select value={condition} sx={{ width: '50%' }}>
+ {[
+ { name: 'Equal', value: 'eq' },
+ { name: 'Not equal', value: 'neq' },
+ { name: 'Greater than', value: 'gt' },
+ { name: 'Greater than or equal', value: 'gte' },
+ { name: 'Less than', value: 'lt' },
+ { name: 'Less than or equal', value: 'lte' },
+ ].map(condition => (
+ <Option
+ key={condition.value}
+ value={condition.value}
+ onClick={() => setCondition(condition.value)}
+ >
+ {condition.name}
+ </Option>
+ ))}
+ </Select>
+ <Input
+ type='number'
+ value={triggerState}
+ onChange={e => setTriggerState(e.target.value)}
+ sx={{ width: '50%' }}
+ />
+ </Box>
+ </Box>
+ )}
+ {selectedThing?.type == 'text' && (
+ <Box>
+ <Typography level='body-sm'>
+ When the state of {selectedThing.name} changes as specified below,
+ the task will become due.
+ </Typography>
+
+ <Input
+ value={triggerState}
+ onChange={e => setTriggerState(e.target.value)}
+ label='Enter the text to trigger the task'
+ />
+ </Box>
+ )}
+ </Card>
+ )
+}
+
+export default ThingTriggerSection
diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx
new file mode 100644
index 0000000..0b2a408
--- /dev/null
+++ b/src/views/Chores/ChoreCard.jsx
@@ -0,0 +1,578 @@
+import {
+ Check,
+ Delete,
+ Edit,
+ HowToReg,
+ KeyboardDoubleArrowUp,
+ LocalOffer,
+ ManageSearch,
+ MoreTime,
+ MoreVert,
+ NoteAdd,
+ RecordVoiceOver,
+ Repeat,
+ Report,
+ SwitchAccessShortcut,
+ TimesOneMobiledata,
+ Update,
+ Webhook,
+} from '@mui/icons-material'
+import {
+ Avatar,
+ Box,
+ Card,
+ Chip,
+ CircularProgress,
+ Divider,
+ Grid,
+ IconButton,
+ Menu,
+ MenuItem,
+ Typography,
+} from '@mui/joy'
+import moment from 'moment'
+import React, { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { API_URL } from '../../Config'
+import { Fetch } from '../../utils/TokenManager'
+import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
+import DateModal from '../Modals/Inputs/DateModal'
+import SelectModal from '../Modals/Inputs/SelectModal'
+import TextModal from '../Modals/Inputs/TextModal'
+const ChoreCard = ({ chore, performers, onChoreUpdate, onChoreRemove, sx }) => {
+ const [activeUserId, setActiveUserId] = React.useState(0)
+ const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
+ React.useState(false)
+ const [isCompleteWithPastDateModalOpen, setIsCompleteWithPastDateModalOpen] =
+ React.useState(false)
+ const [isChangeAssigneeModalOpen, setIsChangeAssigneeModalOpen] =
+ React.useState(false)
+ const [isCompleteWithNoteModalOpen, setIsCompleteWithNoteModalOpen] =
+ React.useState(false)
+ const [confirmModelConfig, setConfirmModelConfig] = React.useState({})
+ const [anchorEl, setAnchorEl] = React.useState(null)
+ const menuRef = React.useRef(null)
+ const navigate = useNavigate()
+ const [isDisabled, setIsDisabled] = React.useState(false)
+
+ // useEffect(() => {
+ // GetAllUsers()
+ // .then(response => response.json())
+ // .then(data => {
+ // setPerformers(data.res)
+ // })
+ // }, [])
+
+ useEffect(() => {
+ document.addEventListener('mousedown', handleMenuOutsideClick)
+ return () => {
+ document.removeEventListener('mousedown', handleMenuOutsideClick)
+ }
+ }, [anchorEl])
+
+ const handleMenuOpen = event => {
+ setAnchorEl(event.currentTarget)
+ }
+
+ const handleMenuClose = () => {
+ setAnchorEl(null)
+ }
+
+ const handleMenuOutsideClick = event => {
+ if (
+ anchorEl &&
+ !anchorEl.contains(event.target) &&
+ !menuRef.current.contains(event.target)
+ ) {
+ handleMenuClose()
+ }
+ }
+ const handleEdit = () => {
+ navigate(`/chores/${chore.id}/edit`)
+ }
+ const handleDelete = () => {
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Delete Chore',
+ confirmText: 'Delete',
+ cancelText: 'Cancel',
+ message: 'Are you sure you want to delete this chore?',
+ onClose: isConfirmed => {
+ console.log('isConfirmed', isConfirmed)
+ if (isConfirmed === true) {
+ Fetch(`${API_URL}/chores/${chore.id}`, {
+ method: 'DELETE',
+ }).then(response => {
+ if (response.ok) {
+ onChoreRemove(chore)
+ }
+ })
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+
+ const handleCompleteChore = () => {
+ Fetch(`${API_URL}/chores/${chore.id}/do`, {
+ method: 'POST',
+ }).then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const newChore = data.res
+ onChoreUpdate(newChore, 'completed')
+ })
+ }
+ })
+ setIsDisabled(true)
+ setTimeout(() => setIsDisabled(false), 5000) // Re-enable the button after 5 seconds
+ }
+ const handleChangeDueDate = newDate => {
+ if (activeUserId === null) {
+ alert('Please select a performer')
+ return
+ }
+ Fetch(`${API_URL}/chores/${chore.id}/dueDate`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ dueDate: newDate ? new Date(newDate).toISOString() : null,
+ UpdatedBy: activeUserId,
+ }),
+ }).then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const newChore = data.res
+ onChoreUpdate(newChore, 'rescheduled')
+ })
+ }
+ })
+ }
+
+ const handleCompleteWithPastDate = newDate => {
+ if (activeUserId === null) {
+ alert('Please select a performer')
+ return
+ }
+ Fetch(
+ `${API_URL}/chores/${chore.id}/do?completedDate=${new Date(
+ newDate,
+ ).toISOString()}`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}),
+ },
+ ).then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const newChore = data.res
+ onChoreUpdate(newChore, 'completed')
+ })
+ }
+ })
+ }
+ const handleAssigneChange = assigneeId => {
+ // TODO: Implement assignee change
+ }
+ const handleCompleteWithNote = note => {
+ Fetch(`${API_URL}/chores/${chore.id}/do`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ note: note,
+ }),
+ }).then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const newChore = data.res
+ onChoreUpdate(newChore, 'completed')
+ })
+ }
+ })
+ }
+ const getDueDateChipText = nextDueDate => {
+ if (chore.nextDueDate === null) return 'No Due Date'
+ // if due in next 48 hours, we should it in this format : Tomorrow 11:00 AM
+ const diff = moment(nextDueDate).diff(moment(), 'hours')
+ if (diff < 48 && diff > 0) {
+ return moment(nextDueDate).calendar().replace(' at', '')
+ }
+ return 'Due ' + moment(nextDueDate).fromNow()
+ }
+ const getDueDateChipColor = nextDueDate => {
+ if (chore.nextDueDate === null) return 'neutral'
+ const diff = moment(nextDueDate).diff(moment(), 'hours')
+ if (diff < 48 && diff > 0) {
+ return 'warning'
+ }
+ if (diff < 0) {
+ return 'danger'
+ }
+
+ return 'neutral'
+ }
+
+ const getIconForLabel = label => {
+ if (!label || label.trim() === '') return <></>
+ switch (String(label).toLowerCase()) {
+ case 'high':
+ return <KeyboardDoubleArrowUp />
+ case 'important':
+ return <Report />
+ default:
+ return <LocalOffer />
+ }
+ }
+
+ const getRecurrentChipText = chore => {
+ const dayOfMonthSuffix = n => {
+ if (n >= 11 && n <= 13) {
+ return 'th'
+ }
+ switch (n % 10) {
+ case 1:
+ return 'st'
+ case 2:
+ return 'nd'
+ case 3:
+ return 'rd'
+ default:
+ return 'th'
+ }
+ }
+ if (chore.frequencyType === 'once') {
+ return 'Once'
+ } else if (chore.frequencyType === 'trigger') {
+ return 'Trigger'
+ } else if (chore.frequencyType === 'daily') {
+ return 'Daily'
+ } else if (chore.frequencyType === 'weekly') {
+ return 'Weekly'
+ } else if (chore.frequencyType === 'monthly') {
+ return 'Monthly'
+ } else if (chore.frequencyType === 'yearly') {
+ return 'Yearly'
+ } else if (chore.frequencyType === 'days_of_the_week') {
+ let days = JSON.parse(chore.frequencyMetadata).days
+ days = days.map(d => moment().day(d).format('ddd'))
+ return days.join(', ')
+ } else if (chore.frequencyType === 'day_of_the_month') {
+ let freqData = JSON.parse(chore.frequencyMetadata)
+ const months = freqData.months.map(m => moment().month(m).format('MMM'))
+ return `${chore.frequency}${dayOfMonthSuffix(
+ chore.frequency,
+ )} of ${months.join(', ')}`
+ } else if (chore.frequencyType === 'interval') {
+ return `Every ${chore.frequency} ${
+ JSON.parse(chore.frequencyMetadata).unit
+ }`
+ } else {
+ return chore.frequencyType
+ }
+ }
+
+ const getFrequencyIcon = chore => {
+ if (['once', 'no_repeat'].includes(chore.frequencyType)) {
+ return <TimesOneMobiledata />
+ } else if (chore.frequencyType === 'trigger') {
+ return <Webhook />
+ } else {
+ return <Repeat />
+ }
+ }
+
+ return (
+ <>
+ <Chip
+ variant='soft'
+ sx={{
+ position: 'relative',
+ top: 10,
+ zIndex: 1,
+ left: 10,
+ }}
+ color={getDueDateChipColor(chore.nextDueDate)}
+ >
+ {getDueDateChipText(chore.nextDueDate)}
+ </Chip>
+
+ <Chip
+ variant='soft'
+ sx={{
+ position: 'relative',
+ top: 10,
+ zIndex: 1,
+ ml: 0.4,
+ left: 10,
+ }}
+ >
+ <div
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ >
+ {getFrequencyIcon(chore)}
+
+ {getRecurrentChipText(chore)}
+ </div>
+ </Chip>
+
+ <Card
+ variant='plain'
+ sx={{
+ ...sx,
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ p: 2,
+ // backgroundColor: 'white',
+ boxShadow: 'sm',
+ borderRadius: 20,
+
+ // mb: 2,
+ }}
+ >
+ <Grid container>
+ <Grid item xs={9}>
+ {/* Box in top right with Chip showing next due date */}
+ <Box display='flex' justifyContent='start' alignItems='center'>
+ <Avatar sx={{ mr: 1, fontSize: 22 }}>
+ {chore.name.charAt(0).toUpperCase()}
+ </Avatar>
+ <Box display='flex' flexDirection='column'>
+ <Typography level='title-md'>{chore.name}</Typography>
+ <Typography level='body-md' color='text.disabled'>
+ Assigned to{' '}
+ <Chip variant='outlined'>
+ {
+ performers.find(p => p.id === chore.assignedTo)
+ ?.displayName
+ }
+ </Chip>
+ </Typography>
+ <Box>
+ {chore.labels?.split(',').map(label => (
+ <Chip
+ variant='solid'
+ key={label}
+ color='primary'
+ sx={{
+ position: 'relative',
+ ml: 0.5,
+ top: 10,
+ zIndex: 1,
+ left: 10,
+ }}
+ startDecorator={getIconForLabel(label)}
+ >
+ {label}
+ </Chip>
+ ))}
+ </Box>
+ </Box>
+ </Box>
+ {/* <Box display='flex' justifyContent='space-between' alignItems='center'>
+ <Chip variant='outlined'>
+ {chore.nextDueDate === null
+ ? '--'
+ : 'Due ' + moment(chore.nextDueDate).fromNow()}
+ </Chip>
+ </Box> */}
+ </Grid>
+ <Grid
+ item
+ xs={3}
+ sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ }}
+ >
+ <Box display='flex' justifyContent='flex-end' alignItems='flex-end'>
+ {/* <ButtonGroup> */}
+ <IconButton
+ variant='solid'
+ color='success'
+ onClick={handleCompleteChore}
+ disabled={isDisabled}
+ sx={{
+ borderRadius: '50%',
+ width: 50,
+ height: 50,
+ zIndex: 1,
+ }}
+ >
+ <div className='relative grid place-items-center'>
+ <Check />
+ {isDisabled && (
+ <CircularProgress
+ variant='solid'
+ color='success'
+ size='md'
+ sx={{
+ color: 'success.main',
+ position: 'absolute',
+ zIndex: 0,
+ }}
+ />
+ )}
+ </div>
+ </IconButton>
+ <IconButton
+ // sx={{ width: 15 }}
+ variant='soft'
+ color='success'
+ onClick={handleMenuOpen}
+ sx={{
+ borderRadius: '50%',
+ width: 25,
+ height: 25,
+ position: 'relative',
+ left: -10,
+ }}
+ >
+ <MoreVert />
+ </IconButton>
+ {/* </ButtonGroup> */}
+ <Menu
+ size='md'
+ ref={menuRef}
+ anchorEl={anchorEl}
+ open={Boolean(anchorEl)}
+ onClose={handleMenuClose}
+ >
+ <MenuItem
+ onClick={() => {
+ setIsCompleteWithNoteModalOpen(true)
+ }}
+ >
+ <NoteAdd />
+ Complete with note
+ </MenuItem>
+ <MenuItem
+ onClick={() => {
+ setIsCompleteWithPastDateModalOpen(true)
+ }}
+ >
+ <Update />
+ Complete in past
+ </MenuItem>
+ <MenuItem
+ onClick={() => {
+ Fetch(`${API_URL}/chores/${chore.id}/skip`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}),
+ }).then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const newChore = data.res
+ onChoreUpdate(newChore, 'skipped')
+ handleMenuClose()
+ })
+ }
+ })
+ }}
+ >
+ <SwitchAccessShortcut />
+ Skip to next due date
+ </MenuItem>
+ <MenuItem
+ onClick={() => {
+ setIsChangeAssigneeModalOpen(true)
+ }}
+ >
+ <RecordVoiceOver />
+ Delegate to someone else
+ </MenuItem>
+ <MenuItem>
+ <HowToReg />
+ Complete as someone else
+ </MenuItem>
+ <Divider />
+ <MenuItem
+ onClick={() => {
+ navigate(`/chores/${chore.id}/history`)
+ }}
+ >
+ <ManageSearch />
+ History
+ </MenuItem>
+ <Divider />
+ <MenuItem
+ onClick={() => {
+ setIsChangeDueDateModalOpen(true)
+ }}
+ >
+ <MoreTime />
+ Change due date
+ </MenuItem>
+ <MenuItem onClick={handleEdit}>
+ <Edit />
+ Edit
+ </MenuItem>
+ <MenuItem onClick={handleDelete} color='danger'>
+ <Delete />
+ Delete
+ </MenuItem>
+ </Menu>
+ </Box>
+ </Grid>
+ </Grid>
+ <DateModal
+ isOpen={isChangeDueDateModalOpen}
+ key={'changeDueDate' + chore.id}
+ current={chore.nextDueDate}
+ title={`Change due date`}
+ onClose={() => {
+ setIsChangeDueDateModalOpen(false)
+ }}
+ onSave={handleChangeDueDate}
+ />
+ <DateModal
+ isOpen={isCompleteWithPastDateModalOpen}
+ key={'completedInPast' + chore.id}
+ current={chore.nextDueDate}
+ title={`Save Chore that you completed in the past`}
+ onClose={() => {
+ setIsCompleteWithPastDateModalOpen(false)
+ }}
+ onSave={handleCompleteWithPastDate}
+ />
+ <SelectModal
+ isOpen={isChangeAssigneeModalOpen}
+ options={performers}
+ displayKey='displayName'
+ title={`Delegate to someone else`}
+ onClose={() => {
+ setIsChangeAssigneeModalOpen(false)
+ }}
+ onSave={handleAssigneChange}
+ />
+ <ConfirmationModal config={confirmModelConfig} />
+ <TextModal
+ isOpen={isCompleteWithNoteModalOpen}
+ title='Add note to attach to this completion:'
+ onClose={() => {
+ setIsCompleteWithNoteModalOpen(false)
+ }}
+ okText={'Complete'}
+ onSave={handleCompleteWithNote}
+ />
+ </Card>
+ </>
+ )
+}
+
+export default ChoreCard
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
new file mode 100644
index 0000000..98fd443
--- /dev/null
+++ b/src/views/Chores/MyChores.jsx
@@ -0,0 +1,384 @@
+import { Add, EditCalendar } from '@mui/icons-material'
+import {
+ Badge,
+ Box,
+ Checkbox,
+ CircularProgress,
+ Container,
+ IconButton,
+ List,
+ ListItem,
+ Menu,
+ MenuItem,
+ Snackbar,
+ Typography,
+} from '@mui/joy'
+import { useContext, useEffect, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { UserContext } from '../../contexts/UserContext'
+import Logo from '../../Logo'
+import { GetAllUsers, GetChores, GetUserProfile } from '../../utils/Fetcher'
+import ChoreCard from './ChoreCard'
+
+const MyChores = () => {
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
+ const [snackBarMessage, setSnackBarMessage] = useState(null)
+ const [chores, setChores] = useState([])
+ const [filteredChores, setFilteredChores] = useState([])
+ const [selectedFilter, setSelectedFilter] = useState('All')
+ const [activeUserId, setActiveUserId] = useState(0)
+ const [performers, setPerformers] = useState([])
+ const [anchorEl, setAnchorEl] = useState(null)
+ const menuRef = useRef(null)
+ const Navigate = useNavigate()
+ const choreSorter = (a, b) => {
+ // 1. Handle null due dates (always last):
+ if (!a.nextDueDate && !b.nextDueDate) return 0 // Both null, no order
+ if (!a.nextDueDate) return 1 // a is null, comes later
+ if (!b.nextDueDate) return -1 // b is null, comes earlier
+
+ const aDueDate = new Date(a.nextDueDate)
+ const bDueDate = new Date(b.nextDueDate)
+ const now = new Date()
+
+ const oneDayInMs = 24 * 60 * 60 * 1000
+
+ // 2. Prioritize tasks due today +- 1 day:
+ const aTodayOrNear = Math.abs(aDueDate - now) <= oneDayInMs
+ const bTodayOrNear = Math.abs(bDueDate - now) <= oneDayInMs
+ if (aTodayOrNear && !bTodayOrNear) return -1 // a is closer
+ if (!aTodayOrNear && bTodayOrNear) return 1 // b is closer
+
+ // 3. Handle overdue tasks (excluding today +- 1):
+ const aOverdue = aDueDate < now && !aTodayOrNear
+ const bOverdue = bDueDate < now && !bTodayOrNear
+ if (aOverdue && !bOverdue) return -1 // a is overdue, comes earlier
+ if (!aOverdue && bOverdue) return 1 // b is overdue, comes earlier
+
+ // 4. Sort future tasks by due date:
+ return aDueDate - bDueDate // Sort ascending by due date
+ }
+
+ const handleSelectedFilter = selected => {
+ setFilteredChores(FILTERS[selected](chores))
+
+ setSelectedFilter(selected)
+ }
+
+ useEffect(() => {
+ if (userProfile === null) {
+ GetUserProfile()
+ .then(response => response.json())
+ .then(data => {
+ setUserProfile(data.res)
+ })
+ }
+ GetChores()
+ .then(response => response.json())
+ .then(data => {
+ data.res.sort(choreSorter)
+ setChores(data.res)
+
+ setFilteredChores(data.res)
+ })
+
+ GetAllUsers()
+ .then(response => response.json())
+ .then(data => {
+ setPerformers(data.res)
+ })
+
+ const currentUser = JSON.parse(localStorage.getItem('user'))
+ if (currentUser !== null) {
+ setActiveUserId(currentUser.id)
+ }
+ }, [])
+ useEffect(() => {
+ document.addEventListener('mousedown', handleMenuOutsideClick)
+ return () => {
+ document.removeEventListener('mousedown', handleMenuOutsideClick)
+ }
+ }, [anchorEl])
+ const handleMenuOutsideClick = event => {
+ if (
+ anchorEl &&
+ !anchorEl.contains(event.target) &&
+ !menuRef.current.contains(event.target)
+ ) {
+ handleFilterMenuClose()
+ }
+ }
+ const handleFilterMenuOpen = event => {
+ event.preventDefault()
+ setAnchorEl(event.currentTarget)
+ }
+
+ const handleFilterMenuClose = () => {
+ setAnchorEl(null)
+ }
+ const handleChoreUpdated = (updatedChore, event) => {
+ const newChores = chores.map(chore => {
+ if (chore.id === updatedChore.id) {
+ return updatedChore
+ }
+ return chore
+ })
+
+ const newFilteredChores = filteredChores.map(chore => {
+ if (chore.id === updatedChore.id) {
+ return updatedChore
+ }
+ return chore
+ })
+ setChores(newChores)
+ setFilteredChores(newFilteredChores)
+ switch (event) {
+ case 'completed':
+ setSnackBarMessage('Completed')
+ break
+ case 'skipped':
+ setSnackBarMessage('Skipped')
+ break
+ case 'rescheduled':
+ setSnackBarMessage('Rescheduled')
+ break
+ default:
+ setSnackBarMessage('Updated')
+ }
+ setIsSnackbarOpen(true)
+ }
+
+ const handleChoreDeleted = deletedChore => {
+ const newChores = chores.filter(chore => chore.id !== deletedChore.id)
+ const newFilteredChores = filteredChores.filter(
+ chore => chore.id !== deletedChore.id,
+ )
+ setChores(newChores)
+ setFilteredChores(newFilteredChores)
+ }
+
+ if (userProfile === null) {
+ return (
+ <Container className='flex h-full items-center justify-center'>
+ <Box className='flex flex-col items-center justify-center'>
+ <CircularProgress
+ color='success'
+ sx={{ '--CircularProgress-size': '200px' }}
+ >
+ <Logo />
+ </CircularProgress>
+ </Box>
+ </Container>
+ )
+ }
+
+ return (
+ <Container maxWidth='md'>
+ {/* <Typography level='h3' mb={1.5}>
+ My Chores
+ </Typography> */}
+ {/* <Sheet> */}
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ '--ListItem-minHeight': '32px',
+ '--ListItem-gap': '4px',
+ mt: 0.2,
+ }}
+ >
+ {['All', 'Overdue', 'Due today', 'Due in week'].map(filter => (
+ <Badge
+ key={filter}
+ anchorOrigin={{
+ vertical: 'top',
+ horizontal: 'right',
+ }}
+ variant='outlined'
+ color={selectedFilter === filter ? 'primary' : 'neutral'}
+ badgeContent={FILTERS[filter](chores).length}
+ badgeInset={'5px'}
+ >
+ <ListItem key={filter}>
+ <Checkbox
+ key={'checkbox' + filter}
+ label={filter}
+ onClick={() => handleSelectedFilter(filter)}
+ checked={filter === selectedFilter}
+ disableIcon
+ overlay
+ size='sm'
+ />
+ </ListItem>
+ </Badge>
+ ))}
+
+ <ListItem onClick={handleFilterMenuOpen}>
+ <Checkbox key='checkboxAll' label='⋮' disableIcon overlay size='lg' />
+ </ListItem>
+ <Menu
+ ref={menuRef}
+ anchorEl={anchorEl}
+ open={Boolean(anchorEl)}
+ onClose={handleFilterMenuClose}
+ >
+ <MenuItem
+ onClick={() => {
+ setFilteredChores(
+ FILTERS['Assigned To Me'](chores, userProfile.id),
+ )
+ setSelectedFilter('Assigned To Me')
+ handleFilterMenuClose()
+ }}
+ >
+ Assigned to me
+ </MenuItem>
+ <MenuItem
+ onClick={() => {
+ setFilteredChores(
+ FILTERS['Created By Me'](chores, userProfile.id),
+ )
+ setSelectedFilter('Created By Me')
+ handleFilterMenuClose()
+ }}
+ >
+ Created by me
+ </MenuItem>
+ <MenuItem
+ onClick={() => {
+ setFilteredChores(FILTERS['No Due Date'](chores, userProfile.id))
+ setSelectedFilter('No Due Date')
+ handleFilterMenuClose()
+ }}
+ >
+ No Due Date
+ </MenuItem>
+ </Menu>
+ </List>
+ {/* </Sheet> */}
+ {filteredChores.length === 0 && (
+ <Box
+ sx={{
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ flexDirection: 'column',
+ height: '50vh',
+ }}
+ >
+ <EditCalendar
+ sx={{
+ fontSize: '4rem',
+ // color: 'text.disabled',
+ mb: 1,
+ }}
+ />
+ <Typography level='title-md' gutterBottom>
+ Nothing scheduled
+ </Typography>
+ </Box>
+ )}
+
+ {filteredChores.map(chore => (
+ <ChoreCard
+ key={chore.id}
+ chore={chore}
+ onChoreUpdate={handleChoreUpdated}
+ onChoreRemove={handleChoreDeleted}
+ performers={performers}
+ />
+ ))}
+
+ <Box
+ // variant='outlined'
+ sx={{
+ position: 'fixed',
+ bottom: 0,
+ left: 10,
+ p: 2, // padding
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: 2,
+ 'z-index': 1000,
+ }}
+ >
+ <IconButton
+ color='primary'
+ variant='solid'
+ sx={{
+ borderRadius: '50%',
+ width: 50,
+ height: 50,
+ }}
+ // startDecorator={<Add />}
+ onClick={() => {
+ Navigate(`/chores/create`)
+ }}
+ >
+ <Add />
+ </IconButton>
+ </Box>
+ <Snackbar
+ open={isSnackbarOpen}
+ onClose={() => {
+ setIsSnackbarOpen(false)
+ }}
+ autoHideDuration={3000}
+ variant='soft'
+ color='success'
+ size='lg'
+ invertedColors
+ >
+ <Typography level='title-md'>{snackBarMessage}</Typography>
+ </Snackbar>
+ </Container>
+ )
+}
+
+const FILTERS = {
+ All: function (chores) {
+ return chores
+ },
+ Overdue: function (chores) {
+ return chores.filter(chore => {
+ if (chore.nextDueDate === null) return false
+ return new Date(chore.nextDueDate) < new Date()
+ })
+ },
+ 'Due today': function (chores) {
+ return chores.filter(chore => {
+ return (
+ new Date(chore.nextDueDate).toDateString() === new Date().toDateString()
+ )
+ })
+ },
+ 'Due in week': function (chores) {
+ return chores.filter(chore => {
+ return (
+ new Date(chore.nextDueDate) <
+ new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
+ new Date(chore.nextDueDate) > new Date()
+ )
+ })
+ },
+ 'Created By Me': function (chores, userID) {
+ return chores.filter(chore => {
+ return chore.createdBy === userID
+ })
+ },
+ 'Assigned To Me': function (chores, userID) {
+ return chores.filter(chore => {
+ return chore.assignedTo === userID
+ })
+ },
+ 'No Due Date': function (chores, userID) {
+ return chores.filter(chore => {
+ return chore.nextDueDate === null
+ })
+ },
+}
+
+export default MyChores
diff --git a/src/views/ChoresOverview.jsx b/src/views/ChoresOverview.jsx
new file mode 100644
index 0000000..396ab0d
--- /dev/null
+++ b/src/views/ChoresOverview.jsx
@@ -0,0 +1,354 @@
+import {
+ Adjust,
+ CancelRounded,
+ CheckBox,
+ Edit,
+ HelpOutline,
+ History,
+ QueryBuilder,
+ SearchRounded,
+ Warning,
+} from '@mui/icons-material'
+import {
+ Avatar,
+ Button,
+ ButtonGroup,
+ Chip,
+ Container,
+ Grid,
+ IconButton,
+ Input,
+ Table,
+ Tooltip,
+ Typography,
+} from '@mui/joy'
+
+import moment from 'moment'
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { API_URL } from '../Config'
+import { GetAllUsers } from '../utils/Fetcher'
+import { Fetch } from '../utils/TokenManager'
+import DateModal from './Modals/Inputs/DateModal'
+// import moment from 'moment'
+
+// enum for chore status:
+const CHORE_STATUS = {
+ NO_DUE_DATE: 'No due date',
+ DUE_SOON: 'Soon',
+ DUE_NOW: 'Due',
+ OVER_DUE: 'Overdue',
+}
+
+const ChoresOverview = () => {
+ const [chores, setChores] = useState([])
+ const [filteredChores, setFilteredChores] = useState([])
+ const [performers, setPerformers] = useState([])
+ const [activeUserId, setActiveUserId] = useState(null)
+ const [isDateModalOpen, setIsDateModalOpen] = useState(false)
+ const [choreId, setChoreId] = useState(null)
+ const [search, setSearch] = useState('')
+ const Navigate = useNavigate()
+
+ const getChoreStatus = chore => {
+ if (chore.nextDueDate === null) {
+ return CHORE_STATUS.NO_DUE_DATE
+ }
+ const dueDate = new Date(chore.nextDueDate)
+ const now = new Date()
+ const diff = dueDate - now
+ if (diff < 0) {
+ return CHORE_STATUS.OVER_DUE
+ }
+ if (diff > 1000 * 60 * 60 * 24) {
+ return CHORE_STATUS.DUE_NOW
+ }
+ if (diff > 0) {
+ return CHORE_STATUS.DUE_SOON
+ }
+ return CHORE_STATUS.NO_DUE_DATE
+ }
+ const getChoreStatusColor = chore => {
+ switch (getChoreStatus(chore)) {
+ case CHORE_STATUS.NO_DUE_DATE:
+ return 'neutral'
+ case CHORE_STATUS.DUE_SOON:
+ return 'success'
+ case CHORE_STATUS.DUE_NOW:
+ return 'primary'
+ case CHORE_STATUS.OVER_DUE:
+ return 'warning'
+ default:
+ return 'neutral'
+ }
+ }
+ const getChoreStatusIcon = chore => {
+ switch (getChoreStatus(chore)) {
+ case CHORE_STATUS.NO_DUE_DATE:
+ return <HelpOutline />
+ case CHORE_STATUS.DUE_SOON:
+ return <QueryBuilder />
+ case CHORE_STATUS.DUE_NOW:
+ return <Adjust />
+ case CHORE_STATUS.OVER_DUE:
+ return <Warning />
+ default:
+ return <HelpOutline />
+ }
+ }
+ useEffect(() => {
+ // fetch chores:
+ Fetch(`${API_URL}/chores/`)
+ .then(response => response.json())
+ .then(data => {
+ const filteredData = data.res.filter(
+ chore => chore.assignedTo === activeUserId || chore.assignedTo === 0,
+ )
+ setChores(data.res)
+ setFilteredChores(data.res)
+ })
+ GetAllUsers()
+ .then(response => response.json())
+ .then(data => {
+ setPerformers(data.res)
+ })
+ const user = JSON.parse(localStorage.getItem('user'))
+ if (user != null && user.id > 0) {
+ setActiveUserId(user.id)
+ }
+ }, [])
+
+ return (
+ <Container>
+ <Typography level='h4' mb={1.5}>
+ Chores Overviews
+ </Typography>
+ {/* <SummaryCard /> */}
+ <Grid container>
+ <Grid
+ item
+ sm={6}
+ alignSelf={'flex-start'}
+ minWidth={100}
+ display='flex'
+ gap={2}
+ >
+ <Input
+ placeholder='Search'
+ value={search}
+ onChange={e => {
+ if (e.target.value === '') {
+ setFilteredChores(chores)
+ }
+ setSearch(e.target.value)
+ const newChores = chores.filter(chore => {
+ return chore.name.includes(e.target.value)
+ })
+ setFilteredChores(newChores)
+ }}
+ endDecorator={
+ search !== '' ? (
+ <Button
+ variant='text'
+ onClick={() => {
+ setSearch('')
+ setFilteredChores(chores)
+ }}
+ >
+ <CancelRounded />
+ </Button>
+ ) : (
+ <Button variant='text'>
+ <SearchRounded />
+ </Button>
+ )
+ }
+ ></Input>
+ </Grid>
+ <Grid item sm={6} justifyContent={'flex-end'} display={'flex'} gap={2}>
+ <Button
+ onClick={() => {
+ Navigate(`/chores/create`)
+ }}
+ >
+ New Chore
+ </Button>
+ </Grid>
+ </Grid>
+
+ <Table>
+ <thead>
+ <tr>
+ {/* first column has minium size because its icon */}
+ <th style={{ width: 100 }}>Due</th>
+ <th>Chore</th>
+ <th>Assignee</th>
+ <th>Due</th>
+ <th>Action</th>
+ </tr>
+ </thead>
+ <tbody>
+ {filteredChores.map(chore => (
+ <tr key={chore.id}>
+ {/* cirular icon if the chore is due will be red else yellow: */}
+ <td>
+ <Chip color={getChoreStatusColor(chore)}>
+ {getChoreStatus(chore)}
+ </Chip>
+ </td>
+ <td
+ onClick={() => {
+ Navigate(`/chores/${chore.id}/edit`)
+ }}
+ >
+ {chore.name || '--'}
+ </td>
+ <td>
+ {chore.assignedTo > 0 ? (
+ <Tooltip
+ title={
+ performers.find(p => p.id === chore.assignedTo)
+ ?.displayName
+ }
+ size='sm'
+ >
+ <Chip
+ startDecorator={
+ <Avatar color='primary'>
+ {
+ performers.find(p => p.id === chore.assignedTo)
+ ?.displayName[0]
+ }
+ </Avatar>
+ }
+ >
+ {performers.find(p => p.id === chore.assignedTo)?.name}
+ </Chip>
+ </Tooltip>
+ ) : (
+ <Chip
+ color='warning'
+ startDecorator={<Avatar color='primary'>?</Avatar>}
+ >
+ Unassigned
+ </Chip>
+ )}
+ </td>
+ <td>
+ <Tooltip
+ title={
+ chore.nextDueDate === null
+ ? 'no due date'
+ : moment(chore.nextDueDate).format('YYYY-MM-DD')
+ }
+ size='sm'
+ >
+ <Typography>
+ {chore.nextDueDate === null
+ ? '--'
+ : moment(chore.nextDueDate).fromNow()}
+ </Typography>
+ </Tooltip>
+ </td>
+
+ <td>
+ <ButtonGroup
+ // display='flex'
+ // // justifyContent='space-around'
+ // alignItems={'center'}
+ // gap={0.5}
+ >
+ <IconButton
+ variant='outlined'
+ size='sm'
+ // sx={{ borderRadius: '50%' }}
+ onClick={() => {
+ Fetch(`${API_URL}/chores/${chore.id}/do`, {
+ method: 'POST',
+ }).then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const newChore = data.res
+ const newChores = [...chores]
+ const index = newChores.findIndex(
+ c => c.id === chore.id,
+ )
+ newChores[index] = newChore
+ setChores(newChores)
+ setFilteredChores(newChores)
+ })
+ }
+ })
+ }}
+ aria-setsize={2}
+ >
+ <CheckBox />
+ </IconButton>
+ <IconButton
+ variant='outlined'
+ size='sm'
+ // sx={{ borderRadius: '50%' }}
+ onClick={() => {
+ setChoreId(chore.id)
+ setIsDateModalOpen(true)
+ }}
+ aria-setsize={2}
+ >
+ <History />
+ </IconButton>
+ <IconButton
+ variant='outlined'
+ size='sm'
+ // sx={{
+ // borderRadius: '50%',
+ // }}
+ onClick={() => {
+ Navigate(`/chores/${chore.id}/edit`)
+ }}
+ >
+ <Edit />
+ </IconButton>
+ </ButtonGroup>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </Table>
+ <DateModal
+ isOpen={isDateModalOpen}
+ key={choreId}
+ title={`Change due date`}
+ onClose={() => {
+ setIsDateModalOpen(false)
+ }}
+ onSave={date => {
+ if (activeUserId === null) {
+ alert('Please select a performer')
+ return
+ }
+ fetch(
+ `${API_URL}/chores/${choreId}/do?performer=${activeUserId}&completedDate=${new Date(
+ date,
+ ).toISOString()}`,
+ {
+ method: 'POST',
+ },
+ ).then(response => {
+ if (response.ok) {
+ response.json().then(data => {
+ const newChore = data.res
+ const newChores = [...chores]
+ const index = newChores.findIndex(c => c.id === chore.id)
+ newChores[index] = newChore
+ setChores(newChores)
+ setFilteredChores(newChores)
+ })
+ }
+ })
+ }}
+ />
+ </Container>
+ )
+}
+
+export default ChoresOverview
diff --git a/src/views/Circles/JoinCircle.jsx b/src/views/Circles/JoinCircle.jsx
new file mode 100644
index 0000000..fd6d542
--- /dev/null
+++ b/src/views/Circles/JoinCircle.jsx
@@ -0,0 +1,154 @@
+import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
+import Logo from '../../Logo'
+
+import { Button } from '@mui/joy'
+import { useContext } from 'react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import { UserContext } from '../../contexts/UserContext'
+import { JoinCircle } from '../../utils/Fetcher'
+const JoinCircleView = () => {
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ let [searchParams, setSearchParams] = useSearchParams()
+ const navigate = useNavigate()
+ const code = searchParams.get('code')
+
+ return (
+ <Container
+ component='main'
+ maxWidth='xs'
+
+ // make content center in the middle of the page:
+ >
+ <Box
+ sx={{
+ marginTop: 4,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ }}
+ >
+ <Sheet
+ component='form'
+ sx={{
+ mt: 1,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: 2,
+ borderRadius: '8px',
+ boxShadow: 'md',
+ }}
+ >
+ {/* <img
+ src='/src/assets/logo.svg'
+ alt='logo'
+ width='128px'
+ height='128px'
+ /> */}
+ <Logo />
+
+ <Typography level='h2'>
+ Done
+ <span
+ style={{
+ color: '#06b6d4',
+ }}
+ >
+ tick
+ </span>
+ </Typography>
+ {code && userProfile && (
+ <>
+ <Typography level='body-md' alignSelf={'center'}>
+ Hi {userProfile?.displayName}, you have been invited to join the
+ circle{' '}
+ </Typography>
+ <Input
+ fullWidth
+ placeholder='Enter code'
+ value={code}
+ disabled={!!code}
+ size='lg'
+ sx={{
+ width: '220px',
+ mb: 1,
+ }}
+ />
+ <Typography level='body-md' alignSelf={'center'}>
+ Joining will give you access to the circle's chores and members.
+ </Typography>
+ <Typography level='body-md' alignSelf={'center'}>
+ You can leave the circle later from you Settings page.
+ </Typography>
+ <Button
+ fullWidth
+ size='lg'
+ sx={{ mt: 3, mb: 2 }}
+ onClick={() => {
+ JoinCircle(code).then(resp => {
+ if (resp.ok) {
+ alert(
+ 'Joined circle successfully, wait for the circle owner to accept your request.',
+ )
+ navigate('/my/chores')
+ } else {
+ if (resp.status === 409) {
+ alert('You are already a member of this circle')
+ } else {
+ alert('Failed to join circle')
+ }
+ navigate('/my/chores')
+ }
+ })
+ }}
+ >
+ Join Circle
+ </Button>
+ <Button
+ fullWidth
+ size='lg'
+ q
+ variant='plain'
+ sx={{
+ width: '100%',
+ mb: 2,
+ border: 'moccasin',
+ borderRadius: '8px',
+ }}
+ onClick={() => {
+ navigate('/my/chores')
+ }}
+ >
+ Cancel
+ </Button>
+ </>
+ )}
+ {!code ||
+ (!userProfile && (
+ <>
+ <Typography level='body-md' alignSelf={'center'}>
+ You need to be logged in to join a circle
+ </Typography>
+ <Typography level='body-md' alignSelf={'center'} sx={{ mb: 9 }}>
+ Login or sign up to continue
+ </Typography>
+ <Button
+ fullWidth
+ size='lg'
+ sx={{ mt: 3, mb: 2 }}
+ onClick={() => {
+ navigate('/login')
+ }}
+ >
+ Login
+ </Button>
+ </>
+ ))}
+ </Sheet>
+ </Box>
+ </Container>
+ )
+}
+
+export default JoinCircleView
diff --git a/src/views/Error.jsx b/src/views/Error.jsx
new file mode 100644
index 0000000..d04fc29
--- /dev/null
+++ b/src/views/Error.jsx
@@ -0,0 +1,11 @@
+import { Typography } from '@mui/joy'
+
+const Error = () => {
+ return (
+ <div className='grid min-h-screen place-items-center'>
+ <Typography level='h1'>404</Typography>
+ </div>
+ )
+}
+
+export default Error
diff --git a/src/views/History/BigChip.jsx b/src/views/History/BigChip.jsx
new file mode 100644
index 0000000..4987367
--- /dev/null
+++ b/src/views/History/BigChip.jsx
@@ -0,0 +1,26 @@
+import Chip from '@mui/joy/Chip'
+import * as React from 'react'
+
+function BigChip(props) {
+ return (
+ <Chip
+ variant='outlined'
+ color='primary'
+ size='lg' // Adjust to your desired size
+ sx={{
+ fontSize: '1rem', // Example: Increase font size
+ padding: '1rem', // Example: Increase padding
+ height: '1rem', // Adjust to your desired height
+ // Add other custom styles as needed
+ }}
+ {...props}
+ >
+ {props.children}
+ </Chip>
+ )
+}
+
+export default BigChip
+BigChip.propTypes = {
+ ...Chip.propTypes,
+}
diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx
new file mode 100644
index 0000000..22ea6a9
--- /dev/null
+++ b/src/views/History/ChoreHistory.jsx
@@ -0,0 +1,344 @@
+import { Checklist, EventBusy, Timelapse } from '@mui/icons-material'
+import {
+ Avatar,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Container,
+ Grid,
+ List,
+ ListDivider,
+ ListItem,
+ ListItemContent,
+ ListItemDecorator,
+ Sheet,
+ Typography,
+} from '@mui/joy'
+import moment from 'moment'
+import React, { useEffect, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { API_URL } from '../../Config'
+import { GetAllCircleMembers } from '../../utils/Fetcher'
+import { Fetch } from '../../utils/TokenManager'
+
+const ChoreHistory = () => {
+ const [choreHistory, setChoresHistory] = useState([])
+ const [userHistory, setUserHistory] = useState([])
+ const [performers, setPerformers] = useState([])
+ const [historyInfo, setHistoryInfo] = useState([])
+
+ const [isLoading, setIsLoading] = useState(true) // Add loading state
+ const { choreId } = useParams()
+
+ useEffect(() => {
+ setIsLoading(true) // Start loading
+
+ Promise.all([
+ Fetch(`${API_URL}/chores/${choreId}/history`).then(res => res.json()),
+ GetAllCircleMembers().then(res => res.json()),
+ ])
+ .then(([historyData, usersData]) => {
+ setChoresHistory(historyData.res)
+
+ const newUserChoreHistory = {}
+ historyData.res.forEach(choreHistory => {
+ const userId = choreHistory.completedBy
+ newUserChoreHistory[userId] = (newUserChoreHistory[userId] || 0) + 1
+ })
+ setUserHistory(newUserChoreHistory)
+
+ setPerformers(usersData.res)
+ updateHistoryInfo(historyData.res, newUserChoreHistory, usersData.res)
+ })
+ .catch(error => {
+ console.error('Error fetching data:', error)
+ // Handle errors, e.g., show an error message to the user
+ })
+ .finally(() => {
+ setIsLoading(false) // Finish loading
+ })
+ }, [choreId])
+
+ const updateHistoryInfo = (histories, userHistories, performers) => {
+ // average delay for task completaion from due date:
+
+ const averageDelay =
+ histories.reduce((acc, chore) => {
+ if (chore.dueDate) {
+ // Only consider chores with a due date
+ return acc + moment(chore.completedAt).diff(chore.dueDate, 'hours')
+ }
+ return acc
+ }, 0) / histories.length
+ const averageDelayMoment = moment.duration(averageDelay, 'hours')
+ const maximumDelay = histories.reduce((acc, chore) => {
+ if (chore.dueDate) {
+ // Only consider chores with a due date
+ const delay = moment(chore.completedAt).diff(chore.dueDate, 'hours')
+ return delay > acc ? delay : acc
+ }
+ return acc
+ }, 0)
+
+ const maxDelayMoment = moment.duration(maximumDelay, 'hours')
+
+ // find max value in userHistories:
+ const userCompletedByMost = Object.keys(userHistories).reduce((a, b) =>
+ userHistories[a] > userHistories[b] ? a : b,
+ )
+ const userCompletedByLeast = Object.keys(userHistories).reduce((a, b) =>
+ userHistories[a] < userHistories[b] ? a : b,
+ )
+
+ const historyInfo = [
+ {
+ icon: (
+ <Avatar>
+ <Checklist />
+ </Avatar>
+ ),
+ text: `${histories.length} completed`,
+ subtext: `${Object.keys(userHistories).length} users contributed`,
+ },
+ {
+ icon: (
+ <Avatar>
+ <Timelapse />
+ </Avatar>
+ ),
+ text: `Completed within ${moment
+ .duration(averageDelayMoment)
+ .humanize()}`,
+ subtext: `Maximum delay was ${moment
+ .duration(maxDelayMoment)
+ .humanize()}`,
+ },
+ {
+ icon: <Avatar></Avatar>,
+ text: `${
+ performers.find(p => p.userId === Number(userCompletedByMost))
+ ?.displayName
+ } completed most`,
+ subtext: `${userHistories[userCompletedByMost]} time/s`,
+ },
+ ]
+ if (userCompletedByLeast !== userCompletedByMost) {
+ historyInfo.push({
+ icon: (
+ <Avatar>
+ {
+ performers.find(p => p.userId === userCompletedByLeast)
+ ?.displayName
+ }
+ </Avatar>
+ ),
+ text: `${
+ performers.find(p => p.userId === Number(userCompletedByLeast))
+ .displayName
+ } completed least`,
+ subtext: `${userHistories[userCompletedByLeast]} time/s`,
+ })
+ }
+
+ setHistoryInfo(historyInfo)
+ }
+
+ function formatTimeDifference(startDate, endDate) {
+ const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
+ let timeValue = diffInMinutes
+ let unit = 'minute'
+
+ if (diffInMinutes >= 60) {
+ const diffInHours = moment(startDate).diff(endDate, 'hours')
+ timeValue = diffInHours
+ unit = 'hour'
+
+ if (diffInHours >= 24) {
+ const diffInDays = moment(startDate).diff(endDate, 'days')
+ timeValue = diffInDays
+ unit = 'day'
+ }
+ }
+
+ return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
+ }
+ if (isLoading) {
+ return <CircularProgress /> // Show loading indicator
+ }
+ if (!choreHistory.length) {
+ return (
+ <Container
+ maxWidth='md'
+ sx={{
+ textAlign: 'center',
+ display: 'flex',
+ // make sure the content is centered vertically:
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'column',
+ height: '50vh',
+ }}
+ >
+ <EventBusy
+ sx={{
+ fontSize: '6rem',
+ // color: 'text.disabled',
+ mb: 1,
+ }}
+ />
+
+ <Typography level='h3' gutterBottom>
+ No History Yet
+ </Typography>
+ <Typography level='body1'>
+ You haven't completed any tasks. Once you start finishing tasks,
+ they'll show up here.
+ </Typography>
+ <Button variant='soft' sx={{ mt: 2 }}>
+ <Link to='/my/chores'>Go back to chores</Link>
+ </Button>
+ </Container>
+ )
+ }
+
+ return (
+ <Container maxWidth='md'>
+ <Typography level='h3' mb={1.5}>
+ Summary:
+ </Typography>
+ {/* <Sheet sx={{ mb: 1, borderRadius: 'sm', p: 2, boxShadow: 'md' }}>
+ <ListItem sx={{ gap: 1.5 }}>
+ <ListItemDecorator>
+ <Avatar>
+ <AccountCircle />
+ </Avatar>
+ </ListItemDecorator>
+ <ListItemContent>
+ <Typography level='body1' sx={{ fontWeight: 'md' }}>
+ {choreHistory.length} completed
+ </Typography>
+ <Typography level='body2' color='text.tertiary'>
+ {Object.keys(userHistory).length} users contributed
+ </Typography>
+ </ListItemContent>
+ </ListItem>
+ </Sheet> */}
+ <Grid container>
+ {historyInfo.map((info, index) => (
+ <Grid key={index} item xs={12} sm={6}>
+ <Sheet sx={{ mb: 1, borderRadius: 'sm', p: 2, boxShadow: 'md' }}>
+ <ListItem sx={{ gap: 1.5 }}>
+ <ListItemDecorator>{info.icon}</ListItemDecorator>
+ <ListItemContent>
+ <Typography level='body1' sx={{ fontWeight: 'md' }}>
+ {info.text}
+ </Typography>
+ <Typography level='body1' color='text.tertiary'>
+ {info.subtext}
+ </Typography>
+ </ListItemContent>
+ </ListItem>
+ </Sheet>
+ </Grid>
+ ))}
+ </Grid>
+ {/* User History Cards */}
+ <Typography level='h3' my={1.5}>
+ History:
+ </Typography>
+ <Box sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md' }}>
+ {/* Chore History List (Updated Style) */}
+
+ <List sx={{ p: 0 }}>
+ {choreHistory.map((chore, index) => (
+ <>
+ <ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }}>
+ {' '}
+ {/* Adjusted spacing and alignment */}
+ <ListItemDecorator>
+ <Avatar sx={{ mr: 1 }}>
+ {performers
+ .find(p => p.userId === chore.completedBy)
+ ?.displayName?.charAt(0) || '?'}
+ </Avatar>
+ </ListItemDecorator>
+ <ListItemContent sx={{ my: 0 }}>
+ {' '}
+ {/* Removed vertical margin */}
+ <Box
+ sx={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ }}
+ >
+ <Typography level='body1' sx={{ fontWeight: 'md' }}>
+ {moment(chore.completedAt).format('ddd MM/DD/yyyy HH:mm')}
+ </Typography>
+
+ <Chip>
+ {chore.dueDate && chore.completedAt > chore.dueDate
+ ? 'Late'
+ : 'On Time'}
+ </Chip>
+ </Box>
+ <Typography level='body2' color='text.tertiary'>
+ <Chip>
+ {
+ performers.find(p => p.userId === chore.completedBy)
+ ?.displayName
+ }
+ </Chip>{' '}
+ completed
+ {chore.completedBy !== chore.assignedTo && (
+ <>
+ {', '}
+ assigned to{' '}
+ <Chip>
+ {
+ performers.find(p => p.userId === chore.assignedTo)
+ ?.displayName
+ }
+ </Chip>
+ </>
+ )}
+ </Typography>
+ {chore.dueDate && (
+ <Typography level='body2' color='text.tertiary'>
+ Due: {moment(chore.dueDate).format('ddd MM/DD/yyyy')}
+ </Typography>
+ )}
+ {chore.notes && (
+ <Typography level='body2' color='text.tertiary'>
+ Note: {chore.notes}
+ </Typography>
+ )}
+ </ListItemContent>
+ </ListItem>
+ {index < choreHistory.length - 1 && (
+ <>
+ <ListDivider component='li'>
+ {/* time between two completion: */}
+ {index < choreHistory.length - 1 &&
+ choreHistory[index + 1].completedAt && (
+ <Typography level='body3' color='text.tertiary'>
+ {formatTimeDifference(
+ chore.completedAt,
+ choreHistory[index + 1].completedAt,
+ )}{' '}
+ before
+ </Typography>
+ )}
+ </ListDivider>
+ </>
+ )}
+ </>
+ ))}
+ </List>
+ </Box>
+ </Container>
+ )
+}
+
+export default ChoreHistory
diff --git a/src/views/History/InfoCard.jsx b/src/views/History/InfoCard.jsx
new file mode 100644
index 0000000..bf1ada8
--- /dev/null
+++ b/src/views/History/InfoCard.jsx
@@ -0,0 +1,26 @@
+import { AddTask } from '@mui/icons-material'
+import { Box } from '@mui/joy'
+import Card from '@mui/joy/Card'
+import CardContent from '@mui/joy/CardContent'
+import Typography from '@mui/joy/Typography'
+import * as React from 'react'
+
+function InfoCard() {
+ return (
+ <Card sx={{ minWidth: 200, maxWidth: 200 }}>
+ <CardContent>
+ <Box mb={2} sx={{ textAlign: 'left' }}>
+ <AddTask
+ sx={{
+ fontSize: '2.5em' /* Increase the font size */,
+ }}
+ />
+ </Box>
+ <Typography level='title-md'>You've completed</Typography>
+ <Typography level='body-sm'>12345 Chores</Typography>
+ </CardContent>
+ </Card>
+ )
+}
+
+export default InfoCard
diff --git a/src/views/Home.jsx b/src/views/Home.jsx
new file mode 100644
index 0000000..4b16a1e
--- /dev/null
+++ b/src/views/Home.jsx
@@ -0,0 +1,46 @@
+import { Box, Button, Container, Typography } from '@mui/joy'
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import { useState } from 'react'
+import Logo from '../Logo'
+const Home = () => {
+ const Navigate = useNavigate()
+ const getCurrentUser = () => {
+ return JSON.parse(localStorage.getItem('user'))
+ }
+ const [users, setUsers] = useState([])
+ const [currentUser, setCurrentUser] = useState(getCurrentUser())
+
+ useEffect(() => {}, [])
+
+ return (
+ <Container className='flex h-full items-center justify-center'>
+ <Box className='flex flex-col items-center justify-center'>
+ <Logo />
+ <Typography level='h1'>
+ Done
+ <span
+ style={{
+ color: '#06b6d4',
+ }}
+ >
+ tick
+ </span>
+ </Typography>
+ </Box>
+ <Box className='flex flex-col items-center justify-center' mt={10}>
+ <Button
+ sx={{ mt: 1 }}
+ onClick={() => {
+ Navigate('/my/chores')
+ }}
+ >
+ Get Started!
+ </Button>
+ </Box>
+ </Container>
+ )
+}
+
+export default Home
diff --git a/src/views/Landing/FeaturesSection.jsx b/src/views/Landing/FeaturesSection.jsx
new file mode 100644
index 0000000..a7da1f0
--- /dev/null
+++ b/src/views/Landing/FeaturesSection.jsx
@@ -0,0 +1,139 @@
+import {
+ AutoAwesomeMosaicOutlined,
+ AutoAwesomeRounded,
+ CodeRounded,
+ GroupRounded,
+ HistoryRounded,
+ Webhook,
+} from '@mui/icons-material'
+import Card from '@mui/joy/Card'
+import Container from '@mui/joy/Container'
+import Typography from '@mui/joy/Typography'
+import { styled } from '@mui/system'
+
+const FeatureIcon = styled('div')({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#f0f0f0', // Adjust the background color as needed
+ borderRadius: '50%',
+ minWidth: '60px',
+ height: '60px',
+ marginRight: '16px',
+})
+
+const CardData = [
+ {
+ title: 'Open Source & Transparent',
+ headline: 'Built for the Community',
+ description:
+ 'Donetick is a community-driven, open-source project. Contribute, customize, and make task management truly yours.',
+ icon: CodeRounded,
+ },
+ {
+ title: 'Circles: Your Task Hub',
+ headline: 'Share & Conquer Together',
+ description:
+ 'Create circles for your family, friends, or team. Easily share tasks and track progress within each group.',
+ icon: GroupRounded,
+ },
+ {
+ title: 'Track Your Progress',
+ headline: "See Who's Done What",
+ description:
+ 'View a history of task completion for each member of your circles. Celebrate successes and stay on top of your goals.',
+ icon: HistoryRounded,
+ },
+ {
+ title: 'Automated Chore Scheduling',
+ headline: 'Fully Customizable Recurring Tasks',
+ description:
+ 'Set up chores to repeat daily, weekly, or monthly. Donetick will automatically assign and track each task for you.',
+ icon: AutoAwesomeMosaicOutlined,
+ },
+ {
+ title: 'Automated Task Assignment',
+ headline: 'Share Responsibilities Equally',
+ description:
+ 'can automatically assigns tasks to each member of your circle. Randomly or based on past completion.',
+ icon: AutoAwesomeRounded,
+ },
+ {
+ title: 'Integrations & Webhooks',
+ headline: 'API & 3rd Party Integrations',
+ description:
+ 'Connect Donetick with your favorite apps and services. Trigger tasks based on events from other platforms.',
+ icon: Webhook,
+ },
+]
+
+function Feature2({ icon: Icon, title, headline, description, index }) {
+ return (
+ <Card
+ variant='plain'
+ sx={{ textAlign: 'left', p: 2 }}
+ data-aos-delay={100 * index}
+ data-aos-anchor='[data-aos-id-features2-blocks]'
+ data-aos='fade-up'
+ >
+ <div style={{ display: 'flex', alignItems: 'center' }}>
+ <FeatureIcon>
+ <Icon
+ color='primary'
+ style={{ Width: '30px', height: '30px' }}
+ stroke={1.5}
+ />
+ </FeatureIcon>
+ <div>
+ {/* Changes are within this div */}
+ <Typography level='h4' mt={1} mb={0.5}>
+ {title}
+ </Typography>
+ <Typography level='body-sm' color='neutral' lineHeight={1.4}>
+ {headline}
+ </Typography>
+ </div>
+ </div>
+ <Typography level='body-md' color='neutral' lineHeight={1.6}>
+ {description}
+ </Typography>
+ </Card>
+ )
+}
+
+function FeaturesSection() {
+ const features = CardData.map((feature, index) => (
+ <Feature2
+ icon={feature.icon}
+ title={feature.title}
+ headline={feature.headline}
+ description={feature.description}
+ index={index}
+ key={index}
+ />
+ ))
+
+ return (
+ <Container sx={{ textAlign: 'center' }}>
+ <Typography level='h4' mt={2} mb={4}>
+ Donetick
+ </Typography>
+
+ <Container maxWidth={'lg'} sx={{ mb: 8 }}>
+ <Typography level='body-md' color='neutral'>
+ Navigate personal growth with genuine insights, thoughtful privacy,
+ and actionable steps tailored just for you.
+ </Typography>
+ </Container>
+
+ <div
+ className='align-center mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3'
+ data-aos-id-features2-blocks
+ >
+ {features}
+ </div>
+ </Container>
+ )
+}
+
+export default FeaturesSection
diff --git a/src/views/Landing/HomeHero.jsx b/src/views/Landing/HomeHero.jsx
new file mode 100644
index 0000000..a96374a
--- /dev/null
+++ b/src/views/Landing/HomeHero.jsx
@@ -0,0 +1,186 @@
+/* eslint-disable tailwindcss/no-custom-classname */
+// import { StyledButton } from '@/components/styled-button'
+import { Button } from '@mui/joy'
+import Typography from '@mui/joy/Typography'
+import Box from '@mui/material/Box'
+import Grid from '@mui/material/Grid'
+import React from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import Logo from '@/assets/logo.svg'
+import screenShotMyChore from '@/assets/screenshot-my-chore.png'
+import { GitHub } from '@mui/icons-material'
+
+const HomeHero = () => {
+ const navigate = useNavigate()
+ const HERO_TEXT_THAT = [
+ // 'Donetick simplifies the entire process, from scheduling and reminders to automatic task assignment and progress tracking.',
+ // 'Donetick is the intuitive task and chore management app designed for groups. Take charge of shared responsibilities, automate your workflow, and achieve more together.',
+ 'An open-source, user-friendly app for managing tasks and chores, featuring customizable options to help you and others stay organized',
+ ]
+
+ const [heroTextIndex, setHeroTextIndex] = React.useState(0)
+
+ React.useEffect(() => {
+ // const intervalId = setInterval(
+ // () => setHeroTextIndex(index => index + 1),
+ // 4000, // every 4 seconds
+ // )
+ // return () => clearTimeout(intervalId)
+ }, [])
+
+ const Title = () => (
+ <Box
+ sx={{
+ textAlign: 'center',
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ }}
+ >
+ <img src={Logo} width={'100px'} />
+ <Typography level='h1' fontSize={58} fontWeight={800}>
+ <span
+ data-aos-delay={50 * 1}
+ data-aos-anchor='[data-aos-id-hero]'
+ data-aos='fade-up'
+ >
+ Done
+ </span>
+ <span
+ data-aos-delay={100 * 3}
+ data-aos-anchor='[data-aos-id-hero]'
+ data-aos='fade-up'
+ style={{
+ color: '#06b6d4',
+ }}
+ >
+ tick
+ </span>
+ </Typography>
+ </Box>
+ )
+
+ const Subtitle = () => (
+ <Typography
+ level='h2'
+ fontWeight={500}
+ textAlign={'center'}
+ className='opacity-70'
+ data-aos-delay={100 * 5}
+ data-aos-anchor='[data-aos-id-hero]'
+ data-aos='zoom-in'
+ >
+ Simplify Tasks & Chores, Together.
+ </Typography>
+ )
+
+ const CTAButton = () => (
+ <Button
+ data-aos-delay={100 * 2}
+ data-aos-anchor='[data-aos-id-hero]'
+ data-aos='fade-up'
+ variant='solid'
+ size='lg'
+ sx={{
+ py: 1.25,
+ px: 5,
+ fontSize: 20,
+ mt: 2,
+ borderWidth: 3,
+ // boxShadow: '0px 0px 24px rgba(81, 230, 221, 0.5)',
+ transition: 'all 0.20s',
+ }}
+ className='hover:scale-105'
+ onClick={() => {
+ // if the url is donetick.com then navigate to app.donetick.com/my/chores
+ // else navigate to /my/chores
+ if (window.location.hostname === 'donetick.com') {
+ window.location.href = 'https://app.donetick.com/my/chores'
+ } else {
+ navigate('/my/chores')
+ }
+ }}
+ >
+ Get started
+ </Button>
+ )
+
+ return (
+ // <Box
+ // id='hero'
+ // className='grid min-h-[90vh] w-full place-items-center px-4 py-12'
+ // data-aos-id-hero
+ // >
+ <Grid container spacing={16} sx={{ py: 12 }}>
+ <Grid item xs={12} md={7}>
+ <Title />
+ <div className='flex flex-col gap-6'>
+ <Subtitle />
+
+ <Typography
+ level='title-lg'
+ textAlign={'center'}
+ fontSize={28}
+ // textColor={'#06b6d4'}
+ color='primary'
+ data-aos-delay={100 * 1}
+ data-aos-anchor='[data-aos-id-hero]'
+ data-aos='fade-up'
+ >
+ {`"${HERO_TEXT_THAT[heroTextIndex % HERO_TEXT_THAT.length]}"`}
+ </Typography>
+
+ <Box className='flex w-full justify-center'>
+ <CTAButton />
+ <Button
+ data-aos-delay={100 * 2.5}
+ data-aos-anchor='[data-aos-id-hero]'
+ data-aos='fade-up'
+ variant='soft'
+ size='lg'
+ sx={{
+ py: 1.25,
+ px: 5,
+ ml: 2,
+ fontSize: 20,
+ mt: 2,
+ borderWidth: 3,
+ // boxShadow: '0px 0px 24px rgba(81, 230, 221, 0.5)',
+ transition: 'all 0.20s',
+ }}
+ className='hover:scale-105'
+ onClick={() => {
+ // new window open to https://github.com/Donetick:
+ window.open('https://github.com/donetick', '_blank')
+ }}
+ startDecorator={<GitHub />}
+ >
+ Github
+ </Button>
+ </Box>
+ </div>
+ </Grid>
+
+ <Grid item xs={12} md={5}>
+ <div className='flex justify-center'>
+ <img
+ src={screenShotMyChore}
+ width={'100%'}
+ style={{
+ maxWidth: 300,
+ }}
+ height={'auto'}
+ alt='Hero img'
+ data-aos-delay={100 * 2}
+ data-aos-anchor='[data-aos-id-hero]'
+ data-aos='fade-left'
+ />
+ </div>
+ </Grid>
+ </Grid>
+ )
+}
+
+export default HomeHero
diff --git a/src/views/Landing/Landing.jsx b/src/views/Landing/Landing.jsx
new file mode 100644
index 0000000..2041e42
--- /dev/null
+++ b/src/views/Landing/Landing.jsx
@@ -0,0 +1,32 @@
+import { Container } from '@mui/joy'
+import AOS from 'aos'
+import 'aos/dist/aos.css'
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import FeaturesSection from './FeaturesSection'
+import HomeHero from './HomeHero'
+import PricingSection from './PricingSection'
+const Landing = () => {
+ const Navigate = useNavigate()
+ const getCurrentUser = () => {
+ return JSON.parse(localStorage.getItem('user'))
+ }
+ const [users, setUsers] = useState([])
+ const [currentUser, setCurrentUser] = useState(getCurrentUser())
+
+ useEffect(() => {
+ AOS.init({
+ once: false, // whether animation should happen only once - while scrolling down
+ })
+ }, [])
+
+ return (
+ <Container className='flex h-full items-center justify-center'>
+ <HomeHero />
+ <FeaturesSection />
+ <PricingSection />
+ </Container>
+ )
+}
+
+export default Landing
diff --git a/src/views/Landing/PricingSection.jsx b/src/views/Landing/PricingSection.jsx
new file mode 100644
index 0000000..634cf7d
--- /dev/null
+++ b/src/views/Landing/PricingSection.jsx
@@ -0,0 +1,179 @@
+/* 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 { useNavigate } from 'react-router-dom'
+
+const PricingSection = () => {
+ const navigate = useNavigate()
+ const FEATURES_FREE = [
+ ['Create Tasks and Chores', <CheckRounded color='primary' />],
+ ['Limited Task History', <CheckRounded color='primary' />],
+ ['Circle up to two members', <CheckRounded color='primary' />],
+ ]
+ const FEATURES_PREMIUM = [
+ ['All Basic Features', <CheckRounded color='primary' />],
+ ['Hosted on DoneTick servers', <CheckRounded color='primary' />],
+ ['Up to 8 Circle Members', <CheckRounded color='primary' />],
+ [
+ 'Notification through Telegram (Discord coming soon)',
+ <CheckRounded color='primary' />,
+ ],
+ ['Unlimited History', <CheckRounded color='primary' />],
+ [
+ 'All circle members get the same features as the owner',
+ <CheckRounded color='primary' />,
+ ],
+ ]
+ const FEATURES_YEARLY = [
+ // ['All Basic Features', <CheckRounded color='primary' />],
+ // ['Up to 8 Circle Members', <CheckRounded color='primary' />],
+ ['Notification through Telegram bot', <CheckRounded color='primary' />],
+ ['Custom Webhook/API Integration', <CheckRounded color='primary' />],
+ ['Unlimited History', <CheckRounded color='primary' />],
+
+ ['Priority Support', <CheckRounded color='primary' />],
+ ]
+ const PRICEITEMS = [
+ {
+ title: 'Basic',
+ description:
+ 'Hosted on Donetick servers, supports up to 2 circle members and includes all the features of the free plan.',
+ price: 0,
+ previousPrice: 0,
+ interval: 'month',
+ discount: false,
+ features: FEATURES_FREE,
+ },
+
+ {
+ title: 'Plus',
+ description:
+ // 'Supports up to 8 circle members and includes all the features of the Basic plan.',
+ 'Hosted on Donetick servers, supports up to 8 circle members and includes all the features of the Basic plan.',
+ price: 30.0,
+ // previousPrice: 76.89,
+ interval: 'year',
+ // discount: true,
+ features: FEATURES_YEARLY,
+ },
+ ]
+ return (
+ <Container
+ sx={{ textAlign: 'center', mb: 2 }}
+ maxWidth={'lg'}
+ id='pricing-tiers'
+ >
+ <Typography level='h4' mt={2} mb={2}>
+ Pricing
+ </Typography>
+
+ <Container maxWidth={'sm'} sx={{ mb: 8 }}>
+ <Typography level='body-md' color='neutral'>
+ Choose the plan that works best for you.
+ </Typography>
+ </Container>
+
+ <div
+ className='mt-8 grid grid-cols-1 gap-2 sm:grid-cols-1 lg:grid-cols-2'
+ data-aos-id-pricing
+ >
+ {PRICEITEMS.map((pi, index) => (
+ <Card
+ key={index}
+ data-aos-delay={50 * (1 + index)}
+ data-aos-anchor='[data-aos-id-pricing]'
+ data-aos='fade-up'
+ className='hover:bg-white dark:hover:bg-teal-900'
+ sx={{
+ textAlign: 'center',
+ p: 5,
+ minHeight: 400,
+ // maxWidth: 400,
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ // when top reach the top change the background color:
+ '&:hover': {
+ // backgroundColor: '#FFFFFF',
+ boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.1)',
+ },
+ }}
+ >
+ <Box
+ display='flex'
+ flexDirection='column'
+ justifyContent='flex-start' // Updated property
+ alignItems='center'
+ >
+ <Typography level='h2'>{pi.title}</Typography>
+ <Typography level='body-md'>{pi.description}</Typography>
+ </Box>
+ <Box
+ display='flex'
+ flexDirection='column'
+ justifyContent='center'
+ alignItems='center'
+ >
+ <Box
+ display='flex'
+ flexDirection='row'
+ alignItems='baseline'
+ sx={{ my: 4 }}
+ >
+ {pi.discount && (
+ <Typography
+ level='h3'
+ component='span'
+ sx={{ textDecoration: 'line-through', opacity: 0.5 }}
+ >
+ ${pi.previousPrice}&nbsp;
+ </Typography>
+ )}
+ <Typography level='h2' component='span'>
+ ${pi.price}
+ </Typography>
+ <Typography level='body-md' component='span'>
+ / {pi.interval}
+ </Typography>
+ </Box>
+
+ <Typography level='title-md'>Features</Typography>
+ {pi.features.map(feature => (
+ <Typography
+ startDecorator={feature[1]}
+ level='body-md'
+ color='neutral'
+ lineHeight={1.6}
+ >
+ {feature[0]}
+ </Typography>
+ ))}
+
+ {/* Here start the test */}
+ <div style={{ marginTop: 'auto' }}>
+ <Button
+ sx={{ mt: 5 }}
+ onClick={() => {
+ navigate('/settings#account')
+ }}
+ >
+ Get Started
+ </Button>
+ <Typography
+ level='body-md'
+ color='neutral'
+ lineHeight={1.6}
+ ></Typography>
+ </div>
+ </Box>
+ </Card>
+ ))}
+ </div>
+
+ {/* Here start the test */}
+ </Container>
+ )
+}
+
+export default PricingSection
diff --git a/src/views/Modals/Inputs/ConfirmationModal.jsx b/src/views/Modals/Inputs/ConfirmationModal.jsx
new file mode 100644
index 0000000..10f9bee
--- /dev/null
+++ b/src/views/Modals/Inputs/ConfirmationModal.jsx
@@ -0,0 +1,43 @@
+import { Box, Button, Modal, ModalDialog, Typography } from '@mui/joy'
+import React from 'react'
+
+function ConfirmationModal({ config }) {
+ const handleAction = isConfirmed => {
+ config.onClose(isConfirmed)
+ }
+
+ return (
+ <Modal open={config?.isOpen} onClose={config?.onClose}>
+ <ModalDialog>
+ <Typography level='h4' mb={1}>
+ {config?.title}
+ </Typography>
+
+ <Typography level='body-md' gutterBottom>
+ {config?.message}
+ </Typography>
+
+ <Box display={'flex'} justifyContent={'space-around'} mt={1}>
+ <Button
+ onClick={() => {
+ handleAction(true)
+ }}
+ fullWidth
+ sx={{ mr: 1 }}
+ >
+ {config?.confirmText}
+ </Button>
+ <Button
+ onClick={() => {
+ handleAction(false)
+ }}
+ variant='outlined'
+ >
+ {config?.cancelText}
+ </Button>
+ </Box>
+ </ModalDialog>
+ </Modal>
+ )
+}
+export default ConfirmationModal
diff --git a/src/views/Modals/Inputs/CreateThingModal.jsx b/src/views/Modals/Inputs/CreateThingModal.jsx
new file mode 100644
index 0000000..59263ff
--- /dev/null
+++ b/src/views/Modals/Inputs/CreateThingModal.jsx
@@ -0,0 +1,112 @@
+import {
+ Box,
+ Button,
+ FormLabel,
+ Input,
+ Modal,
+ ModalDialog,
+ Option,
+ Select,
+ Textarea,
+ Typography,
+} from '@mui/joy'
+import { useEffect, useState } from 'react'
+
+function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
+ const [name, setName] = useState(currentThing?.name || '')
+ const [type, setType] = useState(currentThing?.type || 'numeric')
+ const [state, setState] = useState(currentThing?.state || '')
+ useEffect(() => {
+ if (type === 'boolean') {
+ if (state !== 'true' && state !== 'false') {
+ setState('false')
+ }
+ } else if (type === 'number') {
+ if (isNaN(state)) {
+ setState(0)
+ }
+ }
+ }, [type])
+ const handleSave = () => {
+ onSave({ name, type, id: currentThing?.id, state: state || null })
+ onClose()
+ }
+
+ return (
+ <Modal open={isOpen} onClose={onClose}>
+ <ModalDialog>
+ {/* <ModalClose /> */}
+ <Typography variant='h4'>P;lease add info</Typography>
+ <FormLabel>Name</FormLabel>
+
+ <Textarea
+ placeholder='Thing name'
+ value={name}
+ onChange={e => setName(e.target.value)}
+ sx={{ minWidth: 300 }}
+ />
+ <FormLabel>Type</FormLabel>
+ <Select value={type} sx={{ minWidth: 300 }}>
+ {['text', 'number', 'boolean'].map(type => (
+ <Option value={type} key={type} onClick={() => setType(type)}>
+ {type.charAt(0).toUpperCase() + type.slice(1)}
+ </Option>
+ ))}
+ </Select>
+
+ {type === 'text' && (
+ <>
+ <FormLabel>Value</FormLabel>
+ <Input
+ placeholder='Thing value'
+ value={state || ''}
+ onChange={e => setState(e.target.value)}
+ sx={{ minWidth: 300 }}
+ />
+ </>
+ )}
+ {type === 'number' && (
+ <>
+ <FormLabel>Value</FormLabel>
+ <Input
+ placeholder='Thing value'
+ type='number'
+ value={state || ''}
+ onChange={e => {
+ setState(e.target.value)
+ }}
+ sx={{ minWidth: 300 }}
+ />
+ </>
+ )}
+ {type === 'boolean' && (
+ <>
+ <FormLabel>Value</FormLabel>
+ <Select sx={{ minWidth: 300 }} value={state}>
+ {['true', 'false'].map(value => (
+ <Option
+ value={value}
+ key={value}
+ onClick={() => setState(value)}
+ >
+ {value.charAt(0).toUpperCase() + value.slice(1)}
+ </Option>
+ ))}
+ </Select>
+ </>
+ )}
+
+ <Box display={'flex'} justifyContent={'space-around'} mt={1}>
+ <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
+ {currentThing?.id ? 'Update' : 'Create'}
+ </Button>
+ <Button onClick={onClose} variant='outlined'>
+ {currentThing?.id ? 'Cancel' : 'Close'}
+ </Button>
+ </Box>
+ </ModalDialog>
+ </Modal>
+ )
+}
+
+export default CreateThingModal
diff --git a/src/views/Modals/Inputs/DateModal.jsx b/src/views/Modals/Inputs/DateModal.jsx
new file mode 100644
index 0000000..34319c3
--- /dev/null
+++ b/src/views/Modals/Inputs/DateModal.jsx
@@ -0,0 +1,45 @@
+import React, { useState } from 'react'
+import {
+ Modal,
+ Button,
+ Input,
+ ModalDialog,
+ ModalClose,
+ Box,
+ Typography,
+} from '@mui/joy'
+
+function DateModal({ isOpen, onClose, onSave, current, title }) {
+ const [date, setDate] = useState(
+ current ? new Date(current).toISOString().split('T')[0] : null,
+ )
+
+ const handleSave = () => {
+ onSave(date)
+ onClose()
+ }
+
+ return (
+ <Modal open={isOpen} onClose={onClose}>
+ <ModalDialog>
+ {/* <ModalClose /> */}
+ <Typography variant='h4'>{title}</Typography>
+ <Input
+ sx={{ mt: 3 }}
+ type='date'
+ value={date}
+ onChange={e => setDate(e.target.value)}
+ />
+ <Box display={'flex'} justifyContent={'space-around'} mt={1}>
+ <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
+ Save
+ </Button>
+ <Button onClick={onClose} variant='outlined'>
+ Cancel
+ </Button>
+ </Box>
+ </ModalDialog>
+ </Modal>
+ )
+}
+export default DateModal
diff --git a/src/views/Modals/Inputs/SelectModal.jsx b/src/views/Modals/Inputs/SelectModal.jsx
new file mode 100644
index 0000000..61e7ae9
--- /dev/null
+++ b/src/views/Modals/Inputs/SelectModal.jsx
@@ -0,0 +1,49 @@
+import {
+ Box,
+ Button,
+ Modal,
+ ModalDialog,
+ Option,
+ Select,
+ Typography,
+} from '@mui/joy'
+import React from 'react'
+
+function SelectModal({ isOpen, onClose, onSave, options, title, displayKey }) {
+ const [selected, setSelected] = React.useState(null)
+ const handleSave = () => {
+ onSave(options.find(item => item.id === selected))
+ onClose()
+ }
+
+ return (
+ <Modal open={isOpen} onClose={onClose}>
+ <ModalDialog>
+ <Typography variant='h4'>{title}</Typography>
+ <Select>
+ {options.map((item, index) => (
+ <Option
+ value={item.id}
+ key={item[displayKey]}
+ onClick={() => {
+ setSelected(item.id)
+ }}
+ >
+ {item[displayKey]}
+ </Option>
+ ))}
+ </Select>
+
+ <Box display={'flex'} justifyContent={'space-around'} mt={1}>
+ <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
+ Save
+ </Button>
+ <Button onClick={onClose} variant='outlined'>
+ Cancel
+ </Button>
+ </Box>
+ </ModalDialog>
+ </Modal>
+ )
+}
+export default SelectModal
diff --git a/src/views/Modals/Inputs/TextModal.jsx b/src/views/Modals/Inputs/TextModal.jsx
new file mode 100644
index 0000000..2b44f78
--- /dev/null
+++ b/src/views/Modals/Inputs/TextModal.jsx
@@ -0,0 +1,46 @@
+import { Box, Button, Modal, ModalDialog, Textarea, Typography } from '@mui/joy'
+import { useState } from 'react'
+
+function TextModal({
+ isOpen,
+ onClose,
+ onSave,
+ current,
+ title,
+ okText,
+ cancelText,
+}) {
+ const [text, setText] = useState(current)
+
+ const handleSave = () => {
+ onSave(text)
+ onClose()
+ }
+
+ return (
+ <Modal open={isOpen} onClose={onClose}>
+ <ModalDialog>
+ {/* <ModalClose /> */}
+ <Typography variant='h4'>{title}</Typography>
+ <Textarea
+ placeholder='Type in here…'
+ value={text}
+ onChange={e => setText(e.target.value)}
+ minRows={2}
+ maxRows={4}
+ sx={{ minWidth: 300 }}
+ />
+
+ <Box display={'flex'} justifyContent={'space-around'} mt={1}>
+ <Button onClick={handleSave} fullWidth sx={{ mr: 1 }}>
+ {okText ? okText : 'Save'}
+ </Button>
+ <Button onClick={onClose} variant='outlined'>
+ {cancelText ? cancelText : 'Cancel'}
+ </Button>
+ </Box>
+ </ModalDialog>
+ </Modal>
+ )
+}
+export default TextModal
diff --git a/src/views/NotificationTargets/EditNotificationTarget.jsx b/src/views/NotificationTargets/EditNotificationTarget.jsx
new file mode 100644
index 0000000..ba1a38a
--- /dev/null
+++ b/src/views/NotificationTargets/EditNotificationTarget.jsx
@@ -0,0 +1,51 @@
+import { useEffect, useState } from 'react'
+import { useParams } from 'react-router-dom'
+
+const EditNotificationTarget = () => {
+ const { id } = useParams()
+ const [notificationTarget, setNotificationTarget] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ // const fetchNotificationTarget = async () => {
+ // try {
+ // const response = await fetch(`/api/notification-targets/${id}`)
+ // const data = await response.json()
+ // setNotificationTarget(data)
+ // } catch (error) {
+ // setError(error)
+ // } finally {
+ // setLoading(false)
+ // }
+ // }
+ // fetchNotificationTarget()
+ }, [id])
+
+ if (loading) {
+ return <div>Loading...</div>
+ }
+
+ if (error) {
+ return <div>Error: {error.message}</div>
+ }
+
+ return (
+ <div>
+ <h1>Edit Notification Target</h1>
+ <form>
+ <label>
+ Name:
+ <input type='text' value={notificationTarget.name} />
+ </label>
+ <label>
+ Email:
+ <input type='email' value={notificationTarget.email} />
+ </label>
+ <button type='submit'>Save</button>
+ </form>
+ </div>
+ )
+}
+
+export default EditNotificationTarget
diff --git a/src/views/Payments/PaymentFailView.jsx b/src/views/Payments/PaymentFailView.jsx
new file mode 100644
index 0000000..38fcc2a
--- /dev/null
+++ b/src/views/Payments/PaymentFailView.jsx
@@ -0,0 +1,51 @@
+import { Box, Container, Sheet, Typography } from '@mui/joy'
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import Logo from '../../Logo'
+
+const PaymentCancelledView = () => {
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ navigate('/my/chores')
+ }, 5000)
+ return () => clearTimeout(timer)
+ }, [navigate])
+
+ return (
+ <Container component='main' maxWidth='xs'>
+ <Box
+ sx={{
+ marginTop: 4,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ }}
+ >
+ <Sheet
+ sx={{
+ mt: 1,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: 2,
+ borderRadius: '8px',
+ boxShadow: 'md',
+ }}
+ >
+ <Logo />
+ <Typography level='h2' sx={{ mt: 2, mb: 1 }}>
+ Payment has been cancelled
+ </Typography>
+ <Typography level='body-md' sx={{ mb: 2 }}>
+ You will be redirected to the main page shortly.
+ </Typography>
+ </Sheet>
+ </Box>
+ </Container>
+ )
+}
+
+export default PaymentCancelledView
diff --git a/src/views/Payments/PaymentSuccessView.jsx b/src/views/Payments/PaymentSuccessView.jsx
new file mode 100644
index 0000000..b2fbb50
--- /dev/null
+++ b/src/views/Payments/PaymentSuccessView.jsx
@@ -0,0 +1,51 @@
+import { Box, Container, Sheet, Typography } from '@mui/joy'
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import Logo from '../../Logo'
+
+const PaymentSuccessView = () => {
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ navigate('/settings')
+ }, 5000)
+ return () => clearTimeout(timer)
+ }, [navigate])
+
+ return (
+ <Container component='main' maxWidth='xs'>
+ <Box
+ sx={{
+ marginTop: 4,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ }}
+ >
+ <Sheet
+ sx={{
+ mt: 1,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: 2,
+ borderRadius: '8px',
+ boxShadow: 'md',
+ }}
+ >
+ <Logo />
+ <Typography level='h2' sx={{ mt: 2, mb: 1 }}>
+ Payment Successful!
+ </Typography>
+ <Typography level='body-md' sx={{ mb: 2 }}>
+ You will be redirected to the settings page shortly.
+ </Typography>
+ </Sheet>
+ </Box>
+ </Container>
+ )
+}
+
+export default PaymentSuccessView
diff --git a/src/views/PrivacyPolicy/PrivacyPolicyView.jsx b/src/views/PrivacyPolicy/PrivacyPolicyView.jsx
new file mode 100644
index 0000000..42a4707
--- /dev/null
+++ b/src/views/PrivacyPolicy/PrivacyPolicyView.jsx
@@ -0,0 +1,102 @@
+import React from 'react'
+
+const PrivacyPolicyView = () => {
+ return (
+ <div>
+ <h1>Privacy Policy</h1>
+ <p>
+ Favoro LLC ("we," "us," or "our") operates the Donetick application and
+ website (collectively, the "Service"). This Privacy Policy informs you
+ of our policies regarding the collection, use, and disclosure of
+ personal data when you use our Service and the choices you have
+ associated with that data.
+ </p>
+ <h2>Information We Collect</h2>
+ <p>
+ <strong>Personal Data:</strong> When you register for an account or use
+ the Service, we may collect certain personally identifiable information,
+ such as your name and email address.
+ </p>
+ <p>
+ <strong>Usage Data:</strong> We collect information on how you use the
+ Service, such as your IP address, browser type, pages visited, and the
+ time and date of your visit.
+ </p>
+ <p>
+ <strong>Task Data:</strong> We store the tasks and chores you create
+ within the app, including their details and any assigned users.
+ </p>
+ <h2>How We Use Your Information</h2>
+ <p>
+ <strong>Provide and Maintain the Service:</strong> We use your
+ information to operate, maintain, and improve the Service.
+ </p>
+ <p>
+ <strong>Communicate with You:</strong> We may use your email address to
+ send you notifications, updates, and promotional materials related to
+ the Service.
+ </p>
+ <p>
+ <strong>Analyze Usage:</strong> We analyze usage data to understand how
+ the Service is used and to make improvements.
+ </p>
+ <h2>How We Share Your Information</h2>
+ <p>
+ <strong>With Your Consent:</strong> We will not share your personal data
+ with third parties without your consent, except as described in this
+ Privacy Policy.
+ </p>
+ <p>
+ <strong>Service Providers:</strong> We may engage third-party companies
+ or individuals to perform services on our behalf (e.g., hosting,
+ analytics). These third parties have access to your personal data only
+ to perform these tasks and are obligated not to disclose or use it for
+ any other purpose.
+ </p>
+ <p>
+ <strong>Compliance with Law:</strong> We may disclose your personal data
+ if required to do so by law or in response to valid requests by public
+ authorities (e.g., a court or government agency).
+ </p>
+ <h2>Security</h2>
+ <p>
+ We value your privacy and have implemented reasonable security measures
+ to protect your personal data from unauthorized access, disclosure,
+ alteration, or destruction. However, no method of transmission over the
+ Internet or electronic storage is 100% secure, and we cannot guarantee
+ absolute security.
+ </p>
+ <h2>Your Choices</h2>
+ <p>
+ <strong>Account Information:</strong> You can update or correct your
+ account information at any time.
+ </p>
+ <p>
+ <strong>Marketing Communications:</strong> You can opt out of receiving
+ promotional emails by following the unsubscribe instructions included in
+ those emails.
+ </p>
+ <h2>Children's Privacy</h2>
+ <p>
+ Our Service is not intended for children under 13 years of age. We do
+ not knowingly collect personal data from children under 13. If you are a
+ parent or guardian and you are aware that your child has provided us
+ with personal data, please contact us.
+ </p>
+ <h2>Changes to This Privacy Policy</h2>
+ <p>
+ We may update our Privacy Policy from time to time. We will notify you
+ of any changes by posting the new Privacy Policy on this page and
+ updating the "Effective Date" at the top of this Privacy Policy.
+ </p>
+ <h2>Contact Us</h2>
+ <p>
+ If you have any questions about this Privacy Policy, please contact us
+ at:
+ </p>
+ <p>Favoro LLC</p>
+ </div>
+ )
+}
+
+export default PrivacyPolicyView
diff --git a/src/views/Settings/APITokenSettings.jsx b/src/views/Settings/APITokenSettings.jsx
new file mode 100644
index 0000000..5bd9887
--- /dev/null
+++ b/src/views/Settings/APITokenSettings.jsx
@@ -0,0 +1,130 @@
+import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
+import moment from 'moment'
+import { useContext, useEffect, useState } from 'react'
+import { UserContext } from '../../contexts/UserContext'
+import {
+ CreateLongLiveToken,
+ DeleteLongLiveToken,
+ GetLongLiveTokens,
+} from '../../utils/Fetcher'
+import { isPlusAccount } from '../../utils/Helpers'
+import TextModal from '../Modals/Inputs/TextModal'
+
+const APITokenSettings = () => {
+ const [tokens, setTokens] = useState([])
+ const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ useEffect(() => {
+ GetLongLiveTokens().then(resp => {
+ resp.json().then(data => {
+ setTokens(data.res)
+ })
+ })
+ }, [])
+
+ const handleSaveToken = name => {
+ CreateLongLiveToken(name).then(resp => {
+ if (resp.ok) {
+ resp.json().then(data => {
+ // add the token to the list:
+ console.log(data)
+ const newTokens = [...tokens]
+ newTokens.push(data.res)
+ setTokens(newTokens)
+ })
+ }
+ })
+ }
+
+ return (
+ <div className='grid gap-4 py-4' id='apitokens'>
+ <Typography level='h3'>Long Live 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'>
+ Not available in Basic Plan
+ </Chip>
+ )}
+
+ {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>
+ {token.token && (
+ <Button
+ variant='outlined'
+ color='primary'
+ sx={{ mr: 1 }}
+ onClick={() => {
+ navigator.clipboard.writeText(token.token)
+ alert('Token copied to clipboard')
+ }}
+ >
+ Copy Token
+ </Button>
+ )}
+
+ <Button
+ variant='outlined'
+ color='danger'
+ onClick={() => {
+ const confirmed = confirm(
+ `Are you sure you want to remove ${token.name} ?`,
+ )
+ if (confirmed) {
+ DeleteLongLiveToken(token.id).then(resp => {
+ if (resp.ok) {
+ alert('Token removed')
+ const newTokens = tokens.filter(t => t.id !== token.id)
+ setTokens(newTokens)
+ }
+ })
+ }
+ }}
+ >
+ Remove
+ </Button>
+ </Box>
+ </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}
+ />
+ </div>
+ )
+}
+
+export default APITokenSettings
diff --git a/src/views/Settings/NotificationSetting.jsx b/src/views/Settings/NotificationSetting.jsx
new file mode 100644
index 0000000..4ead3b9
--- /dev/null
+++ b/src/views/Settings/NotificationSetting.jsx
@@ -0,0 +1,90 @@
+import { Button, Divider, Input, Option, Select, Typography } from '@mui/joy'
+import { useContext, useEffect, useState } from 'react'
+import { UserContext } from '../../contexts/UserContext'
+import { GetUserProfile, UpdateUserDetails } from '../../utils/Fetcher'
+
+const NotificationSetting = () => {
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ useEffect(() => {
+ if (!userProfile) {
+ GetUserProfile().then(resp => {
+ resp.json().then(data => {
+ setUserProfile(data.res)
+ setChatID(data.res.chatID)
+ })
+ })
+ }
+ }, [])
+ const [chatID, setChatID] = useState(userProfile?.chatID)
+
+ return (
+ <div className='grid gap-4 py-4' id='notifications'>
+ <Typography level='h3'>Notification Settings</Typography>
+ <Divider />
+ <Typography level='body-md'>Manage your notification settings</Typography>
+
+ <Select defaultValue='telegram' sx={{ maxWidth: '200px' }} disabled>
+ <Option value='telegram'>Telegram</Option>
+ <Option value='discord'>Discord</Option>
+ </Select>
+
+ <Typography level='body-xs'>
+ You need to initiate a message to the bot in order for the Telegram
+ notification to work{' '}
+ <a
+ style={{
+ textDecoration: 'underline',
+ color: '#0891b2',
+ }}
+ href='https://t.me/DonetickBot'
+ >
+ Click here
+ </a>{' '}
+ to start a chat
+ </Typography>
+
+ <Input
+ value={chatID}
+ onChange={e => setChatID(e.target.value)}
+ placeholder='User ID / Chat ID'
+ sx={{
+ width: '200px',
+ }}
+ />
+ <Typography mt={0} level='body-xs'>
+ If you don't know your Chat ID, start chat with userinfobot and it will
+ send you your Chat ID.{' '}
+ <a
+ style={{
+ textDecoration: 'underline',
+ color: '#0891b2',
+ }}
+ href='https://t.me/userinfobot'
+ >
+ Click here
+ </a>{' '}
+ to start chat with userinfobot{' '}
+ </Typography>
+
+ <Button
+ sx={{
+ width: '110px',
+ mb: 1,
+ }}
+ onClick={() => {
+ UpdateUserDetails({
+ chatID: Number(chatID),
+ }).then(resp => {
+ resp.json().then(data => {
+ setUserProfile(data)
+ })
+ })
+ }}
+ >
+ Save
+ </Button>
+ </div>
+ )
+}
+
+export default NotificationSetting
diff --git a/src/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx
new file mode 100644
index 0000000..d612eec
--- /dev/null
+++ b/src/views/Settings/Settings.jsx
@@ -0,0 +1,384 @@
+import {
+ Box,
+ Button,
+ Card,
+ Chip,
+ CircularProgress,
+ Container,
+ Divider,
+ Input,
+ Typography,
+} from '@mui/joy'
+import moment from 'moment'
+import { useContext, useEffect, useState } from 'react'
+import { UserContext } from '../../contexts/UserContext'
+import Logo from '../../Logo'
+import {
+ AcceptCircleMemberRequest,
+ CancelSubscription,
+ DeleteCircleMember,
+ GetAllCircleMembers,
+ GetCircleMemberRequests,
+ GetSubscriptionSession,
+ GetUserCircle,
+ GetUserProfile,
+ JoinCircle,
+ LeaveCircle,
+} from '../../utils/Fetcher'
+import APITokenSettings from './APITokenSettings'
+import NotificationSetting from './NotificationSetting'
+import ThemeToggle from './ThemeToggle'
+
+const Settings = () => {
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ const [userCircles, setUserCircles] = useState([])
+ const [circleMemberRequests, setCircleMemberRequests] = useState([])
+ const [circleInviteCode, setCircleInviteCode] = useState('')
+ const [circleMembers, setCircleMembers] = useState([])
+ useEffect(() => {
+ GetUserProfile().then(resp => {
+ resp.json().then(data => {
+ setUserProfile(data.res)
+ })
+ })
+ GetUserCircle().then(resp => {
+ resp.json().then(data => {
+ setUserCircles(data.res ? data.res : [])
+ })
+ })
+ GetCircleMemberRequests().then(resp => {
+ resp.json().then(data => {
+ setCircleMemberRequests(data.res ? data.res : [])
+ })
+ })
+ GetAllCircleMembers()
+ .then(res => res.json())
+ .then(data => {
+ setCircleMembers(data.res ? data.res : [])
+ })
+ }, [])
+
+ useEffect(() => {
+ const hash = window.location.hash
+ if (hash) {
+ const sharingSection = document.getElementById(
+ window.location.hash.slice(1),
+ )
+ if (sharingSection) {
+ sharingSection.scrollIntoView({ behavior: 'smooth' })
+ }
+ }
+ }, [])
+
+ 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 === 'canceled') {
+ 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 === 'canceled') {
+ if (moment().isBefore(userProfile?.expiration)) {
+ return `Plus(until ${moment(userProfile?.expiration).format(
+ 'MMM DD, YYYY',
+ )})`
+ }
+ return `Free`
+ } else {
+ return `Free`
+ }
+ }
+
+ if (userProfile === null) {
+ return (
+ <Container className='flex h-full items-center justify-center'>
+ <Box className='flex flex-col items-center justify-center'>
+ <CircularProgress
+ color='success'
+ sx={{ '--CircularProgress-size': '200px' }}
+ >
+ <Logo />
+ </CircularProgress>
+ </Box>
+ </Container>
+ )
+ }
+ return (
+ <Container>
+ <div className='grid gap-4 py-4' id='sharing'>
+ <Typography level='h3'>Sharing settings</Typography>
+ <Divider />
+ <Typography level='body-md'>
+ Your account is automatically connected to a Circle when you create or
+ join one. Easily invite friends by sharing the unique Circle code or
+ link below. You'll receive a notification below when someone requests
+ to join your Circle.
+ </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)
+ alert('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}`,
+ )
+ alert('Link Copied to clipboard')
+ }}
+ >
+ Copy Link
+ </Button>
+ {userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
+ <Button
+ sx={{ ml: 1 }}
+ onClick={() => {
+ const confirmed = confirm(
+ `Are you sure you want to leave your circle?`,
+ )
+ if (confirmed) {
+ LeaveCircle(userCircles[0]?.id).then(resp => {
+ if (resp.ok) {
+ alert('Left circle successfully.')
+ } else {
+ alert('Failed to leave circle.')
+ }
+ })
+ }
+ }}
+ >
+ 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>
+ {member.userId !== userProfile.id && member.isActive && (
+ <Button
+ disabled={
+ circleMembers.find(m => userProfile.id == m.userId).role !==
+ 'admin'
+ }
+ variant='outlined'
+ color='danger'
+ size='sm'
+ onClick={() => {
+ const confirmed = confirm(
+ `Are you sure you want to remove ${member.displayName} from your circle?`,
+ )
+ if (confirmed) {
+ DeleteCircleMember(member.circleId, member.userId).then(
+ resp => {
+ if (resp.ok) {
+ alert('Removed member successfully.')
+ }
+ },
+ )
+ }
+ }}
+ >
+ Remove
+ </Button>
+ )}
+ </Box>
+ </Card>
+ ))}
+
+ {circleMemberRequests.length > 0 && (
+ <Typography level='title-md'>Circle Member Requests</Typography>
+ )}
+ {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={() => {
+ const confirmed = confirm(
+ `Are you sure you want to accept ${request.displayName}(username:${request.username}) to join your circle?`,
+ )
+ if (confirmed) {
+ AcceptCircleMemberRequest(request.id).then(resp => {
+ if (resp.ok) {
+ alert('Accepted request successfully.')
+ // reload the page
+ window.location.reload()
+ }
+ })
+ }
+ }}
+ >
+ 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={() => {
+ const confirmed = confirm(
+ `Are you sure you want to leave you circle and join '${circleInviteCode}'?`,
+ )
+ if (confirmed) {
+ JoinCircle(circleInviteCode).then(resp => {
+ if (resp.ok) {
+ alert(
+ 'Joined circle successfully, wait for the circle owner to accept your request.',
+ )
+ }
+ })
+ }
+ }}
+ >
+ Join Circle
+ </Button>
+ </Typography>
+ </div>
+
+ <div className='grid gap-4 py-4' id='account'>
+ <Typography level='h3'>Account Settings</Typography>
+ <Divider />
+ <Typography level='body-md'>
+ Change your account settings, including your password, display name
+ </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())
+ }
+ onClick={() => {
+ GetSubscriptionSession().then(data => {
+ data.json().then(data => {
+ console.log(data)
+ window.location.href = data.sessionURL
+ // open in new window:
+ // window.open(data.sessionURL, '_blank')
+ })
+ })
+ }}
+ >
+ Upgrade
+ </Button>
+
+ {userProfile?.subscription === 'active' && (
+ <Button
+ sx={{
+ width: '110px',
+ mb: 1,
+ ml: 1,
+ }}
+ variant='outlined'
+ onClick={() => {
+ CancelSubscription().then(resp => {
+ if (resp.ok) {
+ alert('Subscription cancelled.')
+ window.location.reload()
+ }
+ })
+ }}
+ >
+ Cancel
+ </Button>
+ )}
+ </Box>
+ </div>
+ <NotificationSetting />
+ <APITokenSettings />
+ <div className='grid gap-4 py-4'>
+ <Typography level='h3'>Theme preferences</Typography>
+ <Divider />
+ <Typography level='body-md'>
+ Choose how the site looks to you. Select a single theme, or sync with
+ your system and automatically switch between day and night themes.
+ </Typography>
+ <ThemeToggle />
+ </div>
+ </Container>
+ )
+}
+
+export default Settings
diff --git a/src/views/Settings/Sharing.jsx b/src/views/Settings/Sharing.jsx
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/views/Settings/Sharing.jsx
diff --git a/src/views/Settings/SharingSettings.jsx b/src/views/Settings/SharingSettings.jsx
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/views/Settings/SharingSettings.jsx
diff --git a/src/views/Settings/ThemeToggle.jsx b/src/views/Settings/ThemeToggle.jsx
new file mode 100644
index 0000000..6ff33f1
--- /dev/null
+++ b/src/views/Settings/ThemeToggle.jsx
@@ -0,0 +1,62 @@
+import useStickyState from '@/hooks/useStickyState'
+import {
+ DarkModeOutlined,
+ LaptopOutlined,
+ LightModeOutlined,
+} from '@mui/icons-material'
+import {
+ Button,
+ FormControl,
+ FormLabel,
+ ToggleButtonGroup,
+ useColorScheme,
+} from '@mui/joy'
+
+const ELEMENTID = 'select-theme-mode'
+
+const ThemeToggle = () => {
+ const { mode, setMode } = useColorScheme()
+ const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
+
+ const handleThemeModeChange = (_, newThemeMode) => {
+ if (!newThemeMode) return
+ setThemeMode(newThemeMode)
+ setMode(newThemeMode)
+ }
+
+ const FormThemeModeToggleLabel = () => (
+ <FormLabel
+ level='title-md'
+ id={`${ELEMENTID}-label`}
+ htmlFor='select-theme-mode'
+ >
+ Theme mode
+ </FormLabel>
+ )
+
+ return (
+ <FormControl>
+ <FormThemeModeToggleLabel />
+ <div className='flex items-center gap-4'>
+ <ToggleButtonGroup
+ id={ELEMENTID}
+ variant='outlined'
+ value={themeMode}
+ onChange={handleThemeModeChange}
+ >
+ <Button startDecorator={<LightModeOutlined />} value='light'>
+ Light
+ </Button>
+ <Button startDecorator={<DarkModeOutlined />} value='dark'>
+ Dark
+ </Button>
+ <Button startDecorator={<LaptopOutlined />} value='system'>
+ System
+ </Button>
+ </ToggleButtonGroup>
+ </div>
+ </FormControl>
+ )
+}
+
+export default ThemeToggle
diff --git a/src/views/SummaryCard.jsx b/src/views/SummaryCard.jsx
new file mode 100644
index 0000000..ac1d23a
--- /dev/null
+++ b/src/views/SummaryCard.jsx
@@ -0,0 +1,31 @@
+import { Card, IconButton, Typography } from '@mui/joy'
+
+const SummaryCard = () => {
+ return (
+ <Card>
+ <div className='flex justify-between'>
+ <div>
+ <Typography level='h2'>Summary</Typography>
+ <Typography level='body-xs'>
+ This is a summary of your chores
+ </Typography>
+ </div>
+ <IconButton>
+ <MoreVert />
+ </IconButton>
+ </div>
+ <div className='flex justify-between'>
+ <div>
+ <Typography level='h3'>Due Today</Typography>
+ <Typography level='h1'>3</Typography>
+ </div>
+ <div>
+ <Typography level='h3'>Overdue</Typography>
+ <Typography level='h1'>1</Typography>
+ </div>
+ </div>
+ </Card>
+ )
+}
+
+export default SummaryCard
diff --git a/src/views/Terms/TermsView.jsx b/src/views/Terms/TermsView.jsx
new file mode 100644
index 0000000..f0a0d9c
--- /dev/null
+++ b/src/views/Terms/TermsView.jsx
@@ -0,0 +1,194 @@
+import React from 'react'
+
+const TermsView = () => {
+ return (
+ <div>
+ <h1>Terms of Service</h1>
+
+ <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
+ these Terms, you may not access or use our services.
+ </p>
+
+ <h2>Use of Services</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>
+ </ul>
+
+ <h2>Fee Changes</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>
+ </ul>
+
+ <h2>Refunds</h2>
+ <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>
+ </ul>
+
+ <h2>Content</h2>
+ <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>
+ </ul>
+ <h2>Feedback Requests</h2>
+
+ <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.
+ </p>
+
+ <p>
+ You may not use our services to send spam, harass others, or engage in
+ any abusive behavior.
+ </p>
+
+ <h2>Credits</h2>
+
+ <p>
+ Certain actions on our platform may require credits. You can purchase
+ credits through our website.
+ </p>
+
+ <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
+ </p>
+ </div>
+ )
+}
+
+export default TermsView
diff --git a/src/views/TestView/IconPicker.jsx b/src/views/TestView/IconPicker.jsx
new file mode 100644
index 0000000..d1bf229
--- /dev/null
+++ b/src/views/TestView/IconPicker.jsx
@@ -0,0 +1,58 @@
+import * as allIcons from '@mui/icons-material' // Import all icons using * as
+import { Grid, Input, SvgIcon } from '@mui/joy'
+import React, { useEffect, useState } from 'react'
+
+function MuiIconPicker({ onIconSelect }) {
+ const [searchTerm, setSearchTerm] = useState('')
+ const [filteredIcons, setFilteredIcons] = useState([])
+ const outlined = Object.keys(allIcons).filter(name =>
+ name.includes('Outlined'),
+ )
+ useEffect(() => {
+ // Filter icons based on the search term
+ setFilteredIcons(
+ outlined.filter(name =>
+ name
+ .toLowerCase()
+ .includes(searchTerm ? searchTerm.toLowerCase() : false),
+ ),
+ )
+ }, [searchTerm])
+
+ const handleIconClick = iconName => {
+ onIconSelect(iconName) // Callback for selected icon
+ }
+
+ return (
+ <div>
+ {/* Autocomplete component for searching */}
+ {JSON.stringify({ 1: searchTerm, filteredIcons: filteredIcons })}
+ <Input
+ onChange={(event, newValue) => {
+ setSearchTerm(newValue)
+ }}
+ />
+ {/* Grid to display icons */}
+ <Grid container spacing={2}>
+ {filteredIcons.map(iconName => {
+ const IconComponent = allIcons[iconName]
+ if (IconComponent) {
+ // Add this check to prevent errors
+ return (
+ <Grid item key={iconName} xs={3} sm={2} md={1}>
+ <SvgIcon
+ component={IconComponent}
+ onClick={() => handleIconClick(iconName)}
+ style={{ cursor: 'pointer' }}
+ />
+ </Grid>
+ )
+ }
+ return null // Return null for non-icon exports
+ })}
+ </Grid>
+ </div>
+ )
+}
+
+export default MuiIconPicker
diff --git a/src/views/TestView/Test.jsx b/src/views/TestView/Test.jsx
new file mode 100644
index 0000000..0494e93
--- /dev/null
+++ b/src/views/TestView/Test.jsx
@@ -0,0 +1,11 @@
+import MuiIconPicker from './IconPicker'
+
+const TestView = () => {
+ return (
+ <div>
+ <MuiIconPicker />
+ </div>
+ )
+}
+
+export default TestView
diff --git a/src/views/Things/ThingsHistory.jsx b/src/views/Things/ThingsHistory.jsx
new file mode 100644
index 0000000..39f0e30
--- /dev/null
+++ b/src/views/Things/ThingsHistory.jsx
@@ -0,0 +1,13 @@
+import { Container, Typography } from '@mui/joy'
+
+const ThingsHistory = () => {
+ return (
+ <Container maxWidth='md'>
+ <Typography level='h3' mb={1.5}>
+ Summary:
+ </Typography>
+ </Container>
+ )
+}
+
+export default ThingsHistory
diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx
new file mode 100644
index 0000000..deb2df5
--- /dev/null
+++ b/src/views/Things/ThingsView.jsx
@@ -0,0 +1,324 @@
+import {
+ Add,
+ Delete,
+ Edit,
+ Flip,
+ PlusOne,
+ ToggleOff,
+ ToggleOn,
+ Widgets,
+} from '@mui/icons-material'
+import {
+ Box,
+ Card,
+ Chip,
+ Container,
+ Grid,
+ IconButton,
+ Typography,
+} from '@mui/joy'
+import { useEffect, useState } from 'react'
+import {
+ CreateThing,
+ DeleteThing,
+ GetThings,
+ SaveThing,
+ UpdateThingState,
+} from '../../utils/Fetcher'
+import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
+import CreateThingModal from '../Modals/Inputs/CreateThingModal'
+
+const ThingCard = ({
+ thing,
+ onEditClick,
+ onStateChangeRequest,
+ onDeleteClick,
+}) => {
+ const getThingIcon = type => {
+ if (type === 'text') {
+ return <Flip />
+ } else if (type === 'number') {
+ return <PlusOne />
+ } else if (type === 'boolean') {
+ if (thing.state === 'true') {
+ return <ToggleOn />
+ } else {
+ return <ToggleOff />
+ }
+ } else {
+ return <ToggleOff />
+ }
+ }
+ return (
+ <Card
+ variant='outlined'
+ sx={{
+ // display: 'flex',
+ // flexDirection: 'row', // Change to 'row'
+ justifyContent: 'space-between',
+ p: 2,
+ backgroundColor: 'white',
+ boxShadow: 'sm',
+ borderRadius: 8,
+ mb: 1,
+ }}
+ >
+ <Grid container>
+ <Grid item xs={9}>
+ <Box
+ sx={{
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 1,
+ }}
+ >
+ <Typography level='title-lg' component='h2'>
+ {thing?.name}
+ </Typography>
+ <Chip level='body-md' component='p'>
+ {thing?.type}
+ </Chip>
+ </Box>
+ <Box>
+ <Typography level='body-sm' component='p'>
+ Current state:
+ <Chip level='title-md' component='span' size='sm'>
+ {thing?.state}
+ </Chip>
+ </Typography>
+ </Box>
+ </Grid>
+ <Grid item xs={3}>
+ <Box display='flex' justifyContent='flex-end' alignItems='flex-end'>
+ {/* <ButtonGroup> */}
+ <IconButton
+ variant='solid'
+ color='success'
+ onClick={() => {
+ onStateChangeRequest(thing)
+ }}
+ sx={{
+ borderRadius: '50%',
+ width: 50,
+ height: 50,
+ zIndex: 1,
+ }}
+ >
+ {getThingIcon(thing?.type)}
+ </IconButton>
+ <IconButton
+ // sx={{ width: 15 }}
+ variant='soft'
+ color='success'
+ onClick={() => {
+ onEditClick(thing)
+ }}
+ sx={{
+ borderRadius: '50%',
+ width: 25,
+ height: 25,
+ position: 'relative',
+ left: -10,
+ }}
+ >
+ <Edit />
+ </IconButton>
+ {/* add delete icon: */}
+ <IconButton
+ // sx={{ width: 15 }}
+
+ color='danger'
+ variant='soft'
+ onClick={() => {
+ onDeleteClick(thing)
+ }}
+ sx={{
+ borderRadius: '50%',
+ width: 25,
+ height: 25,
+ position: 'relative',
+ left: -10,
+ }}
+ >
+ <Delete />
+ </IconButton>
+ </Box>
+ </Grid>
+ </Grid>
+ </Card>
+ )
+}
+
+const ThingsView = () => {
+ const [things, setThings] = useState([])
+ const [isShowCreateThingModal, setIsShowCreateThingModal] = useState(false)
+ const [createModalThing, setCreateModalThing] = useState(null)
+ const [confirmModelConfig, setConfirmModelConfig] = useState({})
+ useEffect(() => {
+ // fetch things
+ GetThings().then(result => {
+ result.json().then(data => {
+ setThings(data.res)
+ })
+ })
+ }, [])
+
+ const handleSaveThing = thing => {
+ let saveFunc = CreateThing
+ if (thing?.id) {
+ saveFunc = SaveThing
+ }
+ saveFunc(thing).then(result => {
+ result.json().then(data => {
+ if (thing?.id) {
+ const currentThings = [...things]
+ const thingIndex = currentThings.findIndex(
+ currentThing => currentThing.id === thing.id,
+ )
+ currentThings[thingIndex] = data.res
+ setThings(currentThings)
+ } else {
+ const currentThings = [...things]
+ currentThings.push(data.res)
+ setThings(currentThings)
+ }
+ })
+ })
+ }
+ const handleEditClick = thing => {
+ setCreateModalThing(thing)
+ setIsShowCreateThingModal(true)
+ }
+ const handleDeleteClick = thing => {
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Delete Things',
+ confirmText: 'Delete',
+ cancelText: 'Cancel',
+ message: 'Are you sure you want to delete this Thing?',
+ onClose: isConfirmed => {
+ if (isConfirmed === true) {
+ DeleteThing(thing.id).then(response => {
+ if (response.ok) {
+ const currentThings = [...things]
+ const thingIndex = currentThings.findIndex(
+ currentThing => currentThing.id === thing.id,
+ )
+ currentThings.splice(thingIndex, 1)
+ setThings(currentThings)
+ }
+ })
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+
+ const handleStateChangeRequest = thing => {
+ if (thing?.type === 'text') {
+ setCreateModalThing(thing)
+ setIsShowCreateThingModal(true)
+ } else {
+ if (thing?.type === 'number') {
+ thing.state = Number(thing.state) + 1
+ } else if (thing?.type === 'boolean') {
+ if (thing.state === 'true') {
+ thing.state = 'false'
+ } else {
+ thing.state = 'true'
+ }
+ }
+
+ UpdateThingState(thing).then(result => {
+ result.json().then(data => {
+ const currentThings = [...things]
+ const thingIndex = currentThings.findIndex(
+ currentThing => currentThing.id === thing.id,
+ )
+ currentThings[thingIndex] = data.res
+ setThings(currentThings)
+ })
+ })
+ }
+ }
+
+ return (
+ <Container maxWidth='md'>
+ {things.length === 0 && (
+ <Box
+ sx={{
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ flexDirection: 'column',
+ height: '50vh',
+ }}
+ >
+ <Widgets
+ sx={{
+ fontSize: '4rem',
+ // color: 'text.disabled',
+ mb: 1,
+ }}
+ />
+ <Typography level='title-md' gutterBottom>
+ No things has been created/found
+ </Typography>
+ </Box>
+ )}
+ {things.map(thing => (
+ <ThingCard
+ key={thing?.id}
+ thing={thing}
+ onEditClick={handleEditClick}
+ onDeleteClick={handleDeleteClick}
+ onStateChangeRequest={handleStateChangeRequest}
+ />
+ ))}
+ <Box
+ // variant='outlined'
+ sx={{
+ position: 'fixed',
+ bottom: 0,
+ left: 10,
+ p: 2, // padding
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: 2,
+
+ 'z-index': 1000,
+ }}
+ >
+ <IconButton
+ color='primary'
+ variant='solid'
+ sx={{
+ borderRadius: '50%',
+ width: 50,
+ height: 50,
+ }}
+ // startDecorator={<Add />}
+ onClick={() => {
+ setIsShowCreateThingModal(true)
+ }}
+ >
+ <Add />
+ </IconButton>
+ {isShowCreateThingModal && (
+ <CreateThingModal
+ isOpen={isShowCreateThingModal}
+ onClose={() => {
+ setIsShowCreateThingModal(false)
+ setCreateModalThing(null)
+ }}
+ onSave={handleSaveThing}
+ currentThing={createModalThing}
+ />
+ )}
+ <ConfirmationModal config={confirmModelConfig} />
+ </Box>
+ </Container>
+ )
+}
+
+export default ThingsView
diff --git a/src/views/components/AutocompleteSelect.jsx b/src/views/components/AutocompleteSelect.jsx
new file mode 100644
index 0000000..7708214
--- /dev/null
+++ b/src/views/components/AutocompleteSelect.jsx
@@ -0,0 +1,87 @@
+import Add from '@mui/icons-material/Add'
+import Autocomplete, { createFilterOptions } from '@mui/joy/Autocomplete'
+import AutocompleteOption from '@mui/joy/AutocompleteOption'
+import FormControl from '@mui/joy/FormControl'
+import ListItemDecorator from '@mui/joy/ListItemDecorator'
+import * as React from 'react'
+
+const filter = createFilterOptions()
+
+export default function FreeSoloCreateOption({ options, onSelectChange }) {
+ React.useEffect(() => {
+ setValue(options)
+ }, [options])
+
+ const [value, setValue] = React.useState([])
+ const [selectOptions, setSelectOptions] = React.useState(
+ options ? options : [],
+ )
+ return (
+ <FormControl id='free-solo-with-text-demo'>
+ <Autocomplete
+ value={value}
+ multiple
+ size='lg'
+ on
+ onChange={(event, newValue) => {
+ if (typeof newValue === 'string') {
+ setValue({
+ title: newValue,
+ })
+ } else if (newValue && newValue.inputValue) {
+ // Create a new value from the user input
+ setValue({
+ title: newValue.inputValue,
+ })
+ } else {
+ setValue(newValue)
+ }
+ onSelectChange(newValue)
+ }}
+ filterOptions={(options, params) => {
+ const filtered = filter(options, params)
+
+ const { inputValue } = params
+ // Suggest the creation of a new value
+ const isExisting = options.some(option => inputValue === option.title)
+ if (inputValue !== '' && !isExisting) {
+ filtered.push({
+ inputValue,
+ title: `Add "${inputValue}"`,
+ })
+ }
+
+ return filtered
+ }}
+ selectOnFocus
+ clearOnBlur
+ handleHomeEndKeys
+ // freeSolo
+ options={selectOptions}
+ getOptionLabel={option => {
+ // Value selected with enter, right from the input
+ if (typeof option === 'string') {
+ return option
+ }
+ // Add "xxx" option created dynamically
+ if (option.inputValue) {
+ return option.inputValue
+ }
+ // Regular option
+ return option.title
+ }}
+ renderOption={(props, option) => (
+ <AutocompleteOption {...props}>
+ {option.title?.startsWith('Add "') && (
+ <ListItemDecorator>
+ <Add />
+ </ListItemDecorator>
+ )}
+
+ {option.title ? option.title : option}
+ </AutocompleteOption>
+ )}
+ />
+ </FormControl>
+ )
+}
diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx
new file mode 100644
index 0000000..25463b2
--- /dev/null
+++ b/src/views/components/NavBar.jsx
@@ -0,0 +1,177 @@
+import Logo from '@/assets/logo.svg'
+import {
+ AccountBox,
+ HomeOutlined,
+ ListAltRounded,
+ Logout,
+ MenuRounded,
+ Message,
+ SettingsOutlined,
+ ShareOutlined,
+ Widgets,
+} from '@mui/icons-material'
+import {
+ Box,
+ Drawer,
+ IconButton,
+ List,
+ ListItemButton,
+ ListItemContent,
+ ListItemDecorator,
+ Typography,
+} from '@mui/joy'
+import { useState } from 'react'
+import { useLocation } from 'react-router-dom'
+import { version } from '../../../package.json'
+import NavBarLink from './NavBarLink'
+const links = [
+ {
+ to: '/my/chores',
+ label: 'Home',
+ icon: <HomeOutlined />,
+ },
+ {
+ to: '/chores',
+ label: 'Desktop View',
+ icon: <ListAltRounded />,
+ },
+ {
+ to: '/things',
+ label: 'Things',
+ icon: <Widgets />,
+ },
+ {
+ to: '/settings#sharing',
+ label: 'Sharing',
+ icon: <ShareOutlined />,
+ },
+ {
+ to: '/settings#notifications',
+ label: 'Notifications',
+ icon: <Message />,
+ },
+ {
+ to: '/settings#account',
+ label: 'Account',
+ icon: <AccountBox />,
+ },
+ {
+ to: '/settings',
+ label: 'Settings',
+ icon: <SettingsOutlined />,
+ },
+]
+
+const NavBar = () => {
+ const [drawerOpen, setDrawerOpen] = useState(false)
+ const [openDrawer, closeDrawer] = [
+ () => setDrawerOpen(true),
+ () => setDrawerOpen(false),
+ ]
+ const location = useLocation()
+ // if url has /landing then remove the navbar:
+ if (
+ ['/', '/signup', '/login', '/landing', '/forgot-password'].includes(
+ location.pathname,
+ )
+ ) {
+ return null
+ }
+
+ return (
+ <nav className='flex gap-2 p-3'>
+ <IconButton size='sm' variant='plain' onClick={() => setDrawerOpen(true)}>
+ <MenuRounded />
+ </IconButton>
+ <Box className='flex items-center gap-2'>
+ <img component='img' src={Logo} width='34' />
+ <Typography
+ level='title-lg'
+ sx={{
+ fontWeight: 700,
+ fontSize: 24,
+ }}
+ >
+ Done
+ <span
+ style={{
+ color: '#06b6d4',
+ fontWeight: 600,
+ }}
+ >
+ tick✓
+ </span>
+ </Typography>
+ </Box>
+ <Drawer
+ open={drawerOpen}
+ onClose={closeDrawer}
+ size='sm'
+ onClick={closeDrawer}
+ >
+ <div>
+ {/* <div className='align-center flex px-5 pt-4'>
+ <ModalClose size='sm' sx={{ top: 'unset', right: 20 }} />
+ </div> */}
+ <List
+ // sx={{ p: 2, height: 'min-content' }}
+ size='md'
+ onClick={openDrawer}
+ sx={{ borderRadius: 4, width: '100%', padding: 1 }}
+ >
+ {links.map((link, index) => (
+ <NavBarLink key={index} link={link} />
+ ))}
+ </List>
+ </div>
+ <div>
+ <List
+ sx={{
+ p: 2,
+ height: 'min-content',
+ position: 'absolute',
+ bottom: 0,
+ borderRadius: 4,
+ width: '100%',
+ padding: 2,
+ }}
+ size='md'
+ onClick={openDrawer}
+ >
+ <ListItemButton
+ onClick={() => {
+ localStorage.removeItem('ca_token')
+ localStorage.removeItem('ca_expiration')
+ // go to login page:
+ window.location.href = '/login'
+ }}
+ sx={{
+ py: 1.2,
+ }}
+ >
+ <ListItemDecorator>
+ <Logout />
+ </ListItemDecorator>
+ <ListItemContent>Logout</ListItemContent>
+ </ListItemButton>
+ <Typography
+ level='body-xs'
+ sx={{
+ // p: 2,
+ p: 1,
+ color: 'text.tertiary',
+ textAlign: 'center',
+ bottom: 0,
+ // mb: -2,
+ }}
+ >
+ V{version}
+ </Typography>
+ </List>
+ </div>
+ </Drawer>
+ </nav>
+ )
+}
+
+export default NavBar
diff --git a/src/views/components/NavBarLink.jsx b/src/views/components/NavBarLink.jsx
new file mode 100644
index 0000000..3fe10d2
--- /dev/null
+++ b/src/views/components/NavBarLink.jsx
@@ -0,0 +1,31 @@
+import {
+ ListItem,
+ ListItemButton,
+ ListItemContent,
+ ListItemDecorator,
+} from '@mui/joy'
+import { Link } from 'react-router-dom'
+
+const NavBarLink = ({ link }) => {
+ const { to, icon, label } = link
+ return (
+ <ListItem>
+ <ListItemButton
+ key={to}
+ component={Link}
+ to={to}
+ variant='plain'
+ color='neutral'
+ sx={{
+ borderRadius: 4,
+ py: 1.2,
+ }}
+ >
+ <ListItemDecorator>{icon}</ListItemDecorator>
+ <ListItemContent>{label}</ListItemContent>
+ </ListItemButton>
+ </ListItem>
+ )
+}
+
+export default NavBarLink
diff --git a/src/views/components/NavBarMobile.jsx b/src/views/components/NavBarMobile.jsx
new file mode 100644
index 0000000..5fb1100
--- /dev/null
+++ b/src/views/components/NavBarMobile.jsx
@@ -0,0 +1,107 @@
+import * as React from 'react'
+import Box from '@mui/joy/Box'
+import ListItemDecorator from '@mui/joy/ListItemDecorator'
+import Tabs from '@mui/joy/Tabs'
+import TabList from '@mui/joy/TabList'
+import Tab, { tabClasses } from '@mui/joy/Tab'
+import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
+import FavoriteBorder from '@mui/icons-material/FavoriteBorder'
+import Search from '@mui/icons-material/Search'
+import Person from '@mui/icons-material/Person'
+
+export default function NavBarMobile() {
+ const [index, setIndex] = React.useState(0)
+ const colors = ['primary', 'danger', 'success', 'warning']
+ return (
+ <Box
+ sx={{
+ position: 'absolute',
+ width: '100%',
+ bottom: 0,
+
+ flexGrow: 1,
+
+ p: 1,
+ borderTopLeftRadius: '12px',
+ borderTopRightRadius: '12px',
+
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ }}
+ >
+ <Tabs
+ size='lg'
+ aria-label='Bottom Navigation'
+ value={index}
+ onChange={(event, value) => setIndex(value)}
+ sx={theme => ({
+ p: 1,
+ borderRadius: 16,
+ maxWidth: 500,
+ // mx: 'auto',
+ boxShadow: theme.shadow.sm,
+ '--joy-shadowChannel': theme.vars.palette[colors[index]].darkChannel,
+ [`& .${tabClasses.root}`]: {
+ py: 1,
+ flex: 1,
+ transition: '0.3s',
+ fontWeight: 'md',
+ fontSize: 'md',
+ [`&:not(.${tabClasses.selected}):not(:hover)`]: {
+ opacity: 0.7,
+ },
+ },
+ })}
+ >
+ <TabList
+ variant='plain'
+ size='sm'
+ disableUnderline
+ sx={{ borderRadius: 'lg', p: 0 }}
+ >
+ <Tab
+ disableIndicator
+ orientation='vertical'
+ {...(index === 0 && { color: colors[0] })}
+ >
+ <ListItemDecorator>
+ <HomeRoundedIcon />
+ </ListItemDecorator>
+ Home
+ </Tab>
+ <Tab
+ disableIndicator
+ orientation='vertical'
+ {...(index === 1 && { color: colors[1] })}
+ >
+ <ListItemDecorator>
+ <FavoriteBorder />
+ </ListItemDecorator>
+ Likes
+ </Tab>
+ <Tab
+ disableIndicator
+ orientation='vertical'
+ {...(index === 2 && { color: colors[2] })}
+ >
+ <ListItemDecorator>
+ <Search />
+ </ListItemDecorator>
+ Search
+ </Tab>
+ <Tab
+ disableIndicator
+ orientation='vertical'
+ {...(index === 3 && { color: colors[3] })}
+ >
+ <ListItemDecorator>
+ <Person />
+ </ListItemDecorator>
+ Profile
+ </Tab>
+ </TabList>
+ </Tabs>
+ </Box>
+ )
+}