move to Donetick Org, First commit frontend
This commit is contained in:
commit
2657469964
105 changed files with 21572 additions and 0 deletions
45
src/views/Authorization/AuthorizationContainer.jsx
Normal file
45
src/views/Authorization/AuthorizationContainer.jsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
227
src/views/Authorization/ForgotPasswordView.jsx
Normal file
227
src/views/Authorization/ForgotPasswordView.jsx
Normal file
|
@ -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
|
345
src/views/Authorization/LoginView.jsx
Normal file
345
src/views/Authorization/LoginView.jsx
Normal file
|
@ -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
|
243
src/views/Authorization/Signup.jsx
Normal file
243
src/views/Authorization/Signup.jsx
Normal file
|
@ -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
|
194
src/views/Authorization/UpdatePasswordView.jsx
Normal file
194
src/views/Authorization/UpdatePasswordView.jsx
Normal file
|
@ -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
|
744
src/views/ChoreEdit/ChoreEdit.jsx
Normal file
744
src/views/ChoreEdit/ChoreEdit.jsx
Normal file
|
@ -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
|
496
src/views/ChoreEdit/RepeatSection.jsx
Normal file
496
src/views/ChoreEdit/RepeatSection.jsx
Normal file
|
@ -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
|
230
src/views/ChoreEdit/ThingTriggerSection.jsx
Normal file
230
src/views/ChoreEdit/ThingTriggerSection.jsx
Normal file
|
@ -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
|
578
src/views/Chores/ChoreCard.jsx
Normal file
578
src/views/Chores/ChoreCard.jsx
Normal file
|
@ -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
|
384
src/views/Chores/MyChores.jsx
Normal file
384
src/views/Chores/MyChores.jsx
Normal file
|
@ -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
|
354
src/views/ChoresOverview.jsx
Normal file
354
src/views/ChoresOverview.jsx
Normal file
|
@ -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
|
154
src/views/Circles/JoinCircle.jsx
Normal file
154
src/views/Circles/JoinCircle.jsx
Normal file
|
@ -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
|
11
src/views/Error.jsx
Normal file
11
src/views/Error.jsx
Normal file
|
@ -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
|
26
src/views/History/BigChip.jsx
Normal file
26
src/views/History/BigChip.jsx
Normal file
|
@ -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,
|
||||
}
|
344
src/views/History/ChoreHistory.jsx
Normal file
344
src/views/History/ChoreHistory.jsx
Normal file
|
@ -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
|
26
src/views/History/InfoCard.jsx
Normal file
26
src/views/History/InfoCard.jsx
Normal file
|
@ -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
|
46
src/views/Home.jsx
Normal file
46
src/views/Home.jsx
Normal file
|
@ -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
|
139
src/views/Landing/FeaturesSection.jsx
Normal file
139
src/views/Landing/FeaturesSection.jsx
Normal file
|
@ -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
|
186
src/views/Landing/HomeHero.jsx
Normal file
186
src/views/Landing/HomeHero.jsx
Normal file
|
@ -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
|
32
src/views/Landing/Landing.jsx
Normal file
32
src/views/Landing/Landing.jsx
Normal file
|
@ -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
|
179
src/views/Landing/PricingSection.jsx
Normal file
179
src/views/Landing/PricingSection.jsx
Normal file
|
@ -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}
|
||||
</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
|
43
src/views/Modals/Inputs/ConfirmationModal.jsx
Normal file
43
src/views/Modals/Inputs/ConfirmationModal.jsx
Normal file
|
@ -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
|
112
src/views/Modals/Inputs/CreateThingModal.jsx
Normal file
112
src/views/Modals/Inputs/CreateThingModal.jsx
Normal file
|
@ -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
|
45
src/views/Modals/Inputs/DateModal.jsx
Normal file
45
src/views/Modals/Inputs/DateModal.jsx
Normal file
|
@ -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
|
49
src/views/Modals/Inputs/SelectModal.jsx
Normal file
49
src/views/Modals/Inputs/SelectModal.jsx
Normal file
|
@ -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
|
46
src/views/Modals/Inputs/TextModal.jsx
Normal file
46
src/views/Modals/Inputs/TextModal.jsx
Normal file
|
@ -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
|
51
src/views/NotificationTargets/EditNotificationTarget.jsx
Normal file
51
src/views/NotificationTargets/EditNotificationTarget.jsx
Normal file
|
@ -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
|
51
src/views/Payments/PaymentFailView.jsx
Normal file
51
src/views/Payments/PaymentFailView.jsx
Normal file
|
@ -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
|
51
src/views/Payments/PaymentSuccessView.jsx
Normal file
51
src/views/Payments/PaymentSuccessView.jsx
Normal file
|
@ -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
|
102
src/views/PrivacyPolicy/PrivacyPolicyView.jsx
Normal file
102
src/views/PrivacyPolicy/PrivacyPolicyView.jsx
Normal file
|
@ -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
|
130
src/views/Settings/APITokenSettings.jsx
Normal file
130
src/views/Settings/APITokenSettings.jsx
Normal file
|
@ -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
|
90
src/views/Settings/NotificationSetting.jsx
Normal file
90
src/views/Settings/NotificationSetting.jsx
Normal file
|
@ -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
|
384
src/views/Settings/Settings.jsx
Normal file
384
src/views/Settings/Settings.jsx
Normal file
|
@ -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
|
0
src/views/Settings/Sharing.jsx
Normal file
0
src/views/Settings/Sharing.jsx
Normal file
0
src/views/Settings/SharingSettings.jsx
Normal file
0
src/views/Settings/SharingSettings.jsx
Normal file
62
src/views/Settings/ThemeToggle.jsx
Normal file
62
src/views/Settings/ThemeToggle.jsx
Normal file
|
@ -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
|
31
src/views/SummaryCard.jsx
Normal file
31
src/views/SummaryCard.jsx
Normal file
|
@ -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
|
194
src/views/Terms/TermsView.jsx
Normal file
194
src/views/Terms/TermsView.jsx
Normal file
|
@ -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
|
58
src/views/TestView/IconPicker.jsx
Normal file
58
src/views/TestView/IconPicker.jsx
Normal file
|
@ -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
|
11
src/views/TestView/Test.jsx
Normal file
11
src/views/TestView/Test.jsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import MuiIconPicker from './IconPicker'
|
||||
|
||||
const TestView = () => {
|
||||
return (
|
||||
<div>
|
||||
<MuiIconPicker />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestView
|
13
src/views/Things/ThingsHistory.jsx
Normal file
13
src/views/Things/ThingsHistory.jsx
Normal file
|
@ -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
|
324
src/views/Things/ThingsView.jsx
Normal file
324
src/views/Things/ThingsView.jsx
Normal file
|
@ -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
|
87
src/views/components/AutocompleteSelect.jsx
Normal file
87
src/views/components/AutocompleteSelect.jsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
177
src/views/components/NavBar.jsx
Normal file
177
src/views/components/NavBar.jsx
Normal file
|
@ -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
|
31
src/views/components/NavBarLink.jsx
Normal file
31
src/views/components/NavBarLink.jsx
Normal file
|
@ -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
|
107
src/views/components/NavBarMobile.jsx
Normal file
107
src/views/components/NavBarMobile.jsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue