move to Donetick Org, First commit frontend

This commit is contained in:
Mo Tarbin 2024-06-30 18:55:39 -04:00
commit 2657469964
105 changed files with 21572 additions and 0 deletions

3
.env.development Normal file
View file

@ -0,0 +1,3 @@
VITE_APP_API_URL=http://localhost:8000
VITE_APP_REDIRECT_URL=http://localhost:5173
VITE_APP_GOOGLE_CLIENT_ID=USE_YOUR_OWN_CLIENT_ID

36
.eslintrc.cjs Normal file
View file

@ -0,0 +1,36 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:prettier/recommended',
'plugin:tailwindcss/recommended',
],
ignorePatterns: [
'dist',
'.eslintrc.cjs',
'tailwind.config.js',
'postcss.config.js',
],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: [
'react-refresh',
'simple-import-sort',
'sort-destructure-keys',
'sort-keys-fix',
'prettier',
'tailwindcss',
],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react/prop-types': 'off',
},
}

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

4
.husky/pre-commit Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

13
.prettierrc Normal file
View file

@ -0,0 +1,13 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"endOfLine": "auto",
"jsxBracketSameLine": false,
"printWidth": 80,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"]
}

52
README.md Normal file
View file

@ -0,0 +1,52 @@
![Logo](assets/image.png)
## Donetick Frontend
The Donetick Frontend is Frontend piece for Donetick written in javascript with React
## What is Donetick?
An open-source, user-friendly app for managing tasks and chores, featuring customizable options to help you and others stay organized.
## Why I made Donetick?
As an avid for open-source, I was eager to create a solution that could benefit the wider community. Donetick started as a personal project to address my own chore management needs, but it has evolved into bigger tool and decide to open source it for anyone seeking a customizable and privacy-focused task management tool
## Features
- Task and Chore Management: Easily create, edit, and manage tasks and chores for yourself or your group.
- Shared To-Do Lists: Create "Circles" to collaborate on tasks with family or your group
- Assignee Assignment: Assign tasks to specific individuals or rotate them automatically using customizable strategies.
- Recurring Tasks: Schedule tasks to repeat daily, weekly, monthly, or yearly, with flexible customization options.
- Progress Tracking: Track the completion status of tasks and view historical data.
## Installation
1. Clone the repository:
2. Navigate to the project directory: `cd frontend`
3. Download dependency `npm install`
4. Run locally `npm start`
## Contributing
Contributions are welcome! If you would like to contribute to Donetick, please follow these steps:
1. Fork the repository
2. Create a new branch: `git checkout -b feature/your-feature-name`
3. Make your changes and commit them: `git commit -m 'Add some feature'`
4. Push to the branch: `git push origin feature/your-feature-name`
5. Submit a pull request
## Need Help:
Donetick is a work in progress and has been a fantastic learning experience for me as I've honed my React skills,I'm looking for collaborators to help improve and refine the Donetick. Feel free to open PR or suggest changes.
## Plans :
My goal is to expand Donetick by offering a hosted infrastructure option. This will make it even easier for users to access and utilize Donetick's features without the need for self-hosting.
While maintaining Donetick's commitment to open source, this hosted option will provide a seamless, out-of-the-box experience for those who prefer a managed solution.
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details. I might consider changing it later to something else

BIN
assets/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

25
index.html Normal file
View file

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<script
defer
data-domain="donetick.com"
src="https://collector.wannaknow.link/js/script.js"
></script>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/logo.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <title>FE Template</title> -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

9481
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

63
package.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": "fe-template",
"private": true,
"version": "0.1.59",
"type": "module",
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
]
},
"scripts": {
"start": "vite --host",
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"release": "npm version patch && npm run build && git push origin develop --tags",
"merge": "git checkout main && git merge develop && git push origin main && git checkout develop"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.2",
"@mui/joy": "^5.0.0-beta.20",
"@mui/material": "^5.15.2",
"@tanstack/react-query": "^5.17.0",
"aos": "^2.3.4",
"dotenv": "^16.4.5",
"js-cookie": "^3.0.5",
"moment": "^2.30.1",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"reactjs-social-login": "^2.6.3",
"vite-plugin-pwa": "^0.20.0"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.14.6",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vite-pwa/assets-generator": "^0.2.4",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-sort-destructure-keys": "^1.5.0",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-tailwindcss": "^3.13.1",
"husky": "^8.0.3",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.5.10",
"tailwindcss": "^3.4.0",
"vite": "^5.2.13"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

9
public/browserconfig.xml Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

1185
public/logo.svg Normal file

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -0,0 +1,40 @@
{
"name": "Donetick: Simplify Tasks & Chores, Together.",
"short_name": "Donetick",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "pwa-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "maskable-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
public/mstile-150x150.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
public/pwa-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

View file

@ -0,0 +1,81 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M4925 10226 c-11 -7 -24 -15 -30 -16 -5 -2 -18 -5 -27 -8 -10 -2 -18
-7 -18 -11 0 -3 -13 -12 -30 -19 -40 -18 -160 -140 -185 -188 -11 -22 -24 -42
-30 -46 -5 -4 -6 -8 -1 -8 5 0 4 -6 -1 -12 -8 -11 -22 -49 -27 -78 -1 -3 -5
-11 -9 -17 -4 -7 -10 -50 -14 -95 -5 -83 9 -165 42 -246 8 -18 14 -35 14 -37
0 -3 10 -15 21 -27 11 -12 20 -28 20 -35 0 -7 3 -13 8 -13 4 0 16 -14 27 -30
11 -16 23 -30 27 -30 4 0 26 -19 49 -42 41 -41 42 -43 18 -45 -13 -1 -184 -4
-379 -6 -331 -4 -386 -8 -450 -34 -8 -4 -18 -7 -22 -8 -5 -2 -9 -3 -10 -5 -2
-1 -11 -3 -20 -5 -32 -6 -168 -77 -168 -88 0 -6 -3 -7 -6 -4 -4 3 -16 1 -27
-5 -13 -7 -166 -11 -451 -13 -237 -1 -440 -5 -451 -7 -11 -3 -41 -9 -67 -12
-26 -4 -50 -11 -53 -17 -4 -5 -10 -7 -15 -4 -6 4 -72 -12 -105 -25 -3 -1 -14
-4 -25 -7 -11 -3 -20 -10 -20 -15 0 -5 -4 -7 -9 -4 -5 4 -30 -3 -55 -14 -26
-11 -49 -19 -53 -19 -3 0 -9 -3 -12 -8 -3 -4 -31 -20 -61 -35 -70 -35 -93 -50
-103 -65 -4 -7 -13 -13 -18 -13 -38 0 -261 -210 -330 -310 -13 -19 -27 -37
-30 -40 -13 -11 -89 -145 -89 -157 0 -7 -4 -13 -10 -13 -5 0 -10 -6 -10 -14 0
-8 -13 -43 -30 -78 -32 -71 -58 -164 -62 -225 -1 -13 -5 -23 -9 -23 -4 0 -6
-15 -5 -34 1 -19 -2 -32 -6 -30 -11 7 -11 -1 -13 -773 0 -474 3 -703 10 -703
7 0 7 -3 0 -8 -14 -10 -16 -112 -1 -112 7 0 7 -3 1 -8 -14 -10 -14 -105 -1
-114 7 -5 7 -8 1 -8 -11 0 -13 -109 -2 -126 3 -6 4 -14 0 -17 -10 -11 -9
-1086 1 -1080 5 4 6 0 1 -8 -13 -19 -12 -668 0 -673 7 -3 7 -5 0 -5 -11 -1
-11 -113 -1 -130 4 -6 4 -11 -1 -11 -10 0 -8 -163 1 -179 4 -6 4 -11 -1 -11
-9 0 -8 -154 2 -170 3 -5 3 -10 -2 -10 -4 0 -6 -81 -3 -181 3 -99 2 -189 -2
-199 -4 -11 -4 -21 0 -24 8 -5 6 -80 -2 -102 -2 -6 -2 -15 1 -18 5 -5 5 -35 2
-151 0 -5 1 -52 2 -103 1 -51 -1 -95 -4 -98 -3 -3 -2 -18 3 -32 4 -15 3 -37
-1 -50 -5 -14 -5 -22 3 -22 7 0 7 -4 -1 -13 -6 -8 -9 -16 -6 -18 9 -9 12 -79
3 -79 -5 0 -6 -5 -3 -10 4 -6 7 -101 7 -213 0 -135 4 -207 12 -216 8 -12 8
-13 -2 -7 -10 6 -12 -2 -11 -33 2 -23 8 -41 13 -41 6 0 5 -4 -3 -9 -13 -8 -13
-12 0 -27 8 -10 9 -15 2 -11 -8 5 -11 -4 -10 -33 1 -22 5 -40 10 -40 4 0 5 -5
1 -12 -10 -15 -13 -68 -4 -68 3 0 6 -9 7 -20 1 -12 -3 -18 -9 -14 -6 4 -6 -1
1 -14 6 -11 11 -37 11 -56 0 -20 3 -36 8 -36 4 0 8 -10 8 -22 1 -25 11 -91 15
-95 1 -2 2 -6 3 -10 7 -37 51 -153 58 -153 4 0 8 -11 8 -25 0 -14 5 -25 10
-25 6 0 10 -9 10 -20 0 -11 5 -20 10 -20 6 0 10 -7 10 -17 0 -9 11 -31 26 -50
14 -18 21 -33 17 -33 -4 0 -3 -4 2 -8 6 -4 23 -26 39 -49 16 -24 50 -65 76
-93 26 -27 45 -50 43 -50 -2 0 11 -15 29 -34 18 -18 36 -31 40 -29 5 2 8 -3 8
-11 0 -9 5 -16 10 -16 6 0 22 -11 35 -25 14 -13 25 -22 25 -20 0 3 9 -3 19
-12 38 -35 50 -43 65 -43 7 0 17 -7 20 -16 4 -9 9 -14 12 -11 4 3 13 -1 21 -9
9 -8 22 -13 29 -10 8 3 11 0 7 -10 -3 -9 1 -14 13 -14 9 0 25 -7 34 -15 9 -8
29 -16 43 -17 15 -1 27 -5 27 -8 0 -3 15 -11 33 -18 17 -8 41 -18 52 -23 11
-6 30 -11 43 -12 12 0 22 -6 22 -11 0 -5 3 -7 6 -4 3 3 31 -1 62 -10 31 -8 68
-16 82 -16 14 -1 32 -6 40 -12 22 -13 4543 -18 4635 -4 109 16 124 19 195 39
104 29 180 59 192 76 4 5 8 7 8 2 0 -4 11 0 25 9 14 9 25 13 25 9 0 -4 7 -1
16 6 17 14 49 34 74 45 19 9 147 102 178 129 58 50 112 108 112 117 0 6 3 9 6
5 7 -7 60 54 56 65 -1 4 4 8 11 8 7 0 31 27 52 61 22 33 48 72 57 87 10 15 23
39 29 55 6 15 15 27 20 27 4 0 6 7 3 15 -4 8 -1 15 5 15 6 0 11 6 11 13 0 6 6
24 14 38 13 25 45 116 47 131 0 4 5 22 10 40 13 42 19 67 23 98 28 185 29 234
22 2430 -11 3831 -10 3729 -33 3780 -3 8 -7 22 -8 30 -1 8 -5 22 -8 30 -4 8
-8 26 -11 40 -2 14 -10 39 -16 55 -33 79 -44 104 -68 155 -36 76 -110 190
-123 190 -6 0 -9 5 -7 11 5 14 -54 78 -65 72 -4 -3 -7 -1 -6 4 3 16 -1 36 -6
31 -3 -3 -36 24 -73 58 -37 35 -71 64 -75 64 -5 0 -21 12 -36 28 -15 15 -41
34 -57 42 -16 8 -38 23 -48 33 -11 9 -25 17 -32 17 -6 0 -17 7 -24 15 -7 8
-16 12 -21 9 -5 -3 -9 0 -9 5 0 6 -9 11 -20 11 -11 0 -20 5 -20 10 0 6 -9 10
-20 10 -11 0 -20 4 -20 8 0 9 -119 52 -144 52 -9 0 -16 4 -16 8 0 7 -24 12
-67 15 -7 0 -13 3 -13 7 0 4 -6 7 -12 7 -7 1 -39 5 -70 10 -40 7 -61 7 -69 -1
-9 -8 -10 -8 -5 2 5 8 0 12 -13 12 -12 0 -21 -6 -21 -12 0 -10 -2 -10 -9 0 -5
9 -31 13 -77 13 -38 0 -181 2 -319 5 -236 5 -252 7 -285 28 -59 38 -205 100
-230 99 -3 0 -16 3 -29 8 -69 27 -260 34 -788 29 -100 -1 -187 1 -193 5 -11 7
11 35 27 35 15 0 143 141 137 151 -3 5 -1 9 4 9 11 0 52 81 52 103 0 9 5 19
11 22 6 4 8 13 5 20 -2 7 -2 16 2 19 16 16 24 183 10 202 -5 7 -8 16 -7 20 1
5 0 12 -1 17 -3 9 -4 13 -15 52 -15 58 -107 200 -138 213 -4 2 -22 17 -40 33
-17 16 -37 29 -44 29 -7 0 -13 4 -13 8 0 5 -10 13 -22 19 -13 6 -36 17 -53 24
-16 8 -36 20 -43 27 -20 18 -330 17 -357 -2z m245 -329 c34 -7 60 -19 60 -27
0 -5 9 -12 21 -15 11 -4 18 -9 15 -12 -3 -3 0 -11 7 -19 32 -35 37 -45 39 -69
1 -14 5 -25 8 -25 3 0 5 -13 5 -30 1 -16 -2 -30 -6 -30 -4 0 -6 -11 -4 -25 1
-14 -1 -25 -6 -25 -4 0 -10 -8 -14 -19 -6 -21 -55 -77 -55 -64 0 4 -4 3 -8 -3
-12 -19 -70 -44 -102 -44 -22 0 -29 -4 -24 -12 6 -10 5 -10 -8 -1 -8 7 -27 14
-42 15 -14 1 -26 6 -26 10 0 4 -6 8 -13 8 -7 0 -23 9 -34 19 -12 11 -24 17
-27 14 -3 -4 -6 1 -6 10 0 10 -4 17 -9 17 -26 0 -60 164 -38 181 4 3 10 16 12
30 3 13 9 25 15 27 6 2 9 7 7 10 -2 4 10 19 25 34 15 15 28 24 28 21 0 -3 7 0
16 8 25 21 103 28 164 16z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

118
src/App.jsx Normal file
View file

@ -0,0 +1,118 @@
import NavBar from '@/views/components/NavBar'
import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy'
import { useEffect, useState } from 'react'
import { Outlet } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { UserContext } from './contexts/UserContext'
import { AuthenticationProvider } from './service/AuthenticationService'
import { GetUserProfile } from './utils/Fetcher'
import { isTokenValid } from './utils/TokenManager'
const add = className => {
document.getElementById('root').classList.add(className)
}
const remove = className => {
document.getElementById('root').classList.remove(className)
}
// TODO: Update the interval to at 60 minutes
const intervalMS = 5 * 60 * 1000 // 5 minutes
function App() {
const { mode, systemMode } = useColorScheme()
const [userProfile, setUserProfile] = useState(null)
const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true)
const {
offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r) {
// eslint-disable-next-line prefer-template
console.log('SW Registered: ' + r)
r &&
setInterval(() => {
r.update()
}, intervalMS)
},
onRegisterError(error) {
console.log('SW registration error', error)
},
})
const close = () => {
setOfflineReady(false)
setNeedRefresh(false)
}
// const updateServiceWorker = useRegisterSW({
// onRegistered(r) {
// r &&
// setInterval(() => {
// r.update()
// }, intervalMS)
// },
// })
const setThemeClass = () => {
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
if (value === 'system') {
if (systemMode === 'dark') {
return add('dark')
}
return remove('dark')
}
if (value === 'dark') {
return add('dark')
}
return remove('dark')
}
const getUserProfile = () => {
GetUserProfile()
.then(res => {
res.json().then(data => {
setUserProfile(data.res)
})
})
.catch(error => {})
}
useEffect(() => {
setThemeClass()
}, [mode, systemMode])
useEffect(() => {
if (isTokenValid()) {
if (!userProfile) getUserProfile()
}
}, [])
return (
<div className='min-h-screen'>
<AuthenticationProvider />
<UserContext.Provider value={{ userProfile, setUserProfile }}>
<NavBar />
<Outlet />
</UserContext.Provider>
{needRefresh && (
<Snackbar open={showUpdateSnackbar}>
<Typography level='body-md'>
A new version is now available.Click on reload button to update.
</Typography>
<Button
color='secondary'
size='small'
onClick={() => {
updateServiceWorker(true)
setShowUpdateSnackbar(false)
}}
>
Refresh
</Button>
</Snackbar>
)}
</div>
)
}
export default App

5
src/Config.js Normal file
View file

@ -0,0 +1,5 @@
/* eslint-env node */
export const API_URL = import.meta.env.VITE_APP_API_URL //|| 'http://localhost:8000'
export const REDIRECT_URL = import.meta.env.VITE_APP_REDIRECT_URL //|| 'http://localhost:3000'
export const GOOGLE_CLIENT_ID = import.meta.env.VITE_APP_GOOGLE_CLIENT_ID
export const ENVIROMENT = import.meta.env.VITE_APP_ENVIROMENT

9
src/Logo.jsx Normal file
View file

@ -0,0 +1,9 @@
import LogoSVG from '@/assets/logo.svg'
const Logo = () => {
return (
<div className='logo'>
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
</div>
)
}
export default Logo

1185
src/assets/logo.svg Normal file

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

6
src/constants/theme.js Normal file
View file

@ -0,0 +1,6 @@
import resolveConfig from 'tailwindcss/resolveConfig'
import tailwindConfig from '/tailwind.config.js'
export const { theme: THEME } = resolveConfig(tailwindConfig)
export const COLORS = THEME.colors

13
src/contexts/Contexts.jsx Normal file
View file

@ -0,0 +1,13 @@
import QueryContext from './QueryContext'
import RouterContext from './RouterContext'
import ThemeContext from './ThemeContext'
const Contexts = () => {
const contexts = [ThemeContext, QueryContext, RouterContext]
return contexts.reduceRight((acc, Context) => {
return <Context>{acc}</Context>
}, {})
}
export default Contexts

View file

@ -0,0 +1,11 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const QueryContext = ({ children }) => {
const queryClient = new QueryClient()
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
export default QueryContext

View file

@ -0,0 +1,116 @@
import App from '@/App'
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
import ChoresOverview from '@/views/ChoresOverview'
import Error from '@/views/Error'
import Settings from '@/views/Settings/Settings'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
import LoginView from '../views/Authorization/LoginView'
import SignupView from '../views/Authorization/Signup'
import UpdatePasswordView from '../views/Authorization/UpdatePasswordView'
import MyChores from '../views/Chores/MyChores'
import JoinCircleView from '../views/Circles/JoinCircle'
import ChoreHistory from '../views/History/ChoreHistory'
import Landing from '../views/Landing/Landing'
import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
import TermsView from '../views/Terms/TermsView'
import TestView from '../views/TestView/Test'
import ThingsHistory from '../views/Things/ThingsHistory'
import ThingsView from '../views/Things/ThingsView'
const Router = createBrowserRouter([
{
path: '/',
element: <App />,
errorElement: <Error />,
children: [
{
path: '/',
element: <Landing />,
},
{
path: '/settings',
element: <Settings />,
},
{
path: '/chores',
element: <ChoresOverview />,
},
{
path: '/chores/:choreId/edit',
element: <ChoreEdit />,
},
{
path: '/chores/create',
element: <ChoreEdit />,
},
{
path: '/chores/:choreId/history',
element: <ChoreHistory />,
},
{
path: '/my/chores',
element: <MyChores />,
},
{
path: '/login',
element: <LoginView />,
},
{
path: '/signup',
element: <SignupView />,
},
{
path: '/landing',
element: <Landing />,
},
{
path: '/test',
element: <TestView />,
},
{
path: '/forgot-password',
element: <ForgotPasswordView />,
},
{
path: '/password/update',
element: <UpdatePasswordView />,
},
{
path: '/privacy',
element: <PrivacyPolicyView />,
},
{
path: '/terms',
element: <TermsView />,
},
{
path: 'circle/join',
element: <JoinCircleView />,
},
{
path: 'payments/success',
element: <PaymentSuccessView />,
},
{
path: 'payments/cancel',
element: <PaymentCancelledView />,
},
{
path: 'things',
element: <ThingsView />,
},
{
path: 'things/:id',
element: <ThingsHistory />,
},
],
},
])
const RouterContext = ({ children }) => {
return <RouterProvider router={Router} />
}
export default RouterContext

View file

@ -0,0 +1,86 @@
import { COLORS } from '@/constants/theme'
import { CssBaseline } from '@mui/joy'
import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
import PropType from 'prop-types'
const primaryColor = 'cyan'
const shades = [
'50',
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
]
const getPallete = (key = primaryColor) => {
return shades.reduce((acc, shade) => {
acc[shade] = COLORS[key][shade]
return acc
}, {})
}
const primaryPalette = getPallete(primaryColor)
const theme = extendTheme({
colorSchemes: {
light: {
palette: {
primary: primaryPalette,
success: {
50: '#f3faf7',
100: '#def5eb',
200: '#b7e7d5',
300: '#8ed9be',
400: '#6ecdb0',
500: '#4ec1a2',
600: '#46b89a',
700: '#3cae91',
800: '#32a487',
900: '#229d76',
},
danger: {
50: '#fef2f2',
100: '#fde8e8',
200: '#fbd5d5',
300: '#f9c1c1',
400: '#f6a8a8',
500: '',
600: '#f47272',
700: '#e33434',
800: '#cc1f1a',
900: '#b91c1c',
},
},
warning: {
50: '#fffdf7',
100: '#fef8e1',
200: '#fdecb2',
300: '#fcd982',
400: '#fbcf52',
500: '#f9c222',
600: '#f6b81e',
700: '#f3ae1a',
800: '#f0a416',
900: '#e99b0e',
},
},
},
dark: {
palette: {
primary: primaryPalette,
},
},
})
const ThemeContext = ({ children }) => {
return (
<CssVarsProvider theme={theme}>
<CssBaseline />
{children}
</CssVarsProvider>
)
}
ThemeContext.propTypes = {
children: PropType.node,
}
export default ThemeContext

View file

@ -0,0 +1,8 @@
import { createContext } from 'react'
const UserContext = createContext({
userProfile: null,
setUserProfile: () => {},
})
export { UserContext }

View file

@ -0,0 +1,16 @@
import { useEffect, useState } from 'react'
const useStickyState = (defaultValue, key) => {
const [value, setValue] = useState(() => {
const stickyValue = window.localStorage.getItem(key)
return stickyValue !== null ? JSON.parse(stickyValue) : defaultValue
})
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue]
}
export default useStickyState

3
src/index.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import Contexts from './contexts/Contexts.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Contexts />
</React.StrictMode>,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/manifest/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1185
src/manifest/logo.svg Normal file

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View file

@ -0,0 +1,81 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M4925 10226 c-11 -7 -24 -15 -30 -16 -5 -2 -18 -5 -27 -8 -10 -2 -18
-7 -18 -11 0 -3 -13 -12 -30 -19 -40 -18 -160 -140 -185 -188 -11 -22 -24 -42
-30 -46 -5 -4 -6 -8 -1 -8 5 0 4 -6 -1 -12 -8 -11 -22 -49 -27 -78 -1 -3 -5
-11 -9 -17 -4 -7 -10 -50 -14 -95 -5 -83 9 -165 42 -246 8 -18 14 -35 14 -37
0 -3 10 -15 21 -27 11 -12 20 -28 20 -35 0 -7 3 -13 8 -13 4 0 16 -14 27 -30
11 -16 23 -30 27 -30 4 0 26 -19 49 -42 41 -41 42 -43 18 -45 -13 -1 -184 -4
-379 -6 -331 -4 -386 -8 -450 -34 -8 -4 -18 -7 -22 -8 -5 -2 -9 -3 -10 -5 -2
-1 -11 -3 -20 -5 -32 -6 -168 -77 -168 -88 0 -6 -3 -7 -6 -4 -4 3 -16 1 -27
-5 -13 -7 -166 -11 -451 -13 -237 -1 -440 -5 -451 -7 -11 -3 -41 -9 -67 -12
-26 -4 -50 -11 -53 -17 -4 -5 -10 -7 -15 -4 -6 4 -72 -12 -105 -25 -3 -1 -14
-4 -25 -7 -11 -3 -20 -10 -20 -15 0 -5 -4 -7 -9 -4 -5 4 -30 -3 -55 -14 -26
-11 -49 -19 -53 -19 -3 0 -9 -3 -12 -8 -3 -4 -31 -20 -61 -35 -70 -35 -93 -50
-103 -65 -4 -7 -13 -13 -18 -13 -38 0 -261 -210 -330 -310 -13 -19 -27 -37
-30 -40 -13 -11 -89 -145 -89 -157 0 -7 -4 -13 -10 -13 -5 0 -10 -6 -10 -14 0
-8 -13 -43 -30 -78 -32 -71 -58 -164 -62 -225 -1 -13 -5 -23 -9 -23 -4 0 -6
-15 -5 -34 1 -19 -2 -32 -6 -30 -11 7 -11 -1 -13 -773 0 -474 3 -703 10 -703
7 0 7 -3 0 -8 -14 -10 -16 -112 -1 -112 7 0 7 -3 1 -8 -14 -10 -14 -105 -1
-114 7 -5 7 -8 1 -8 -11 0 -13 -109 -2 -126 3 -6 4 -14 0 -17 -10 -11 -9
-1086 1 -1080 5 4 6 0 1 -8 -13 -19 -12 -668 0 -673 7 -3 7 -5 0 -5 -11 -1
-11 -113 -1 -130 4 -6 4 -11 -1 -11 -10 0 -8 -163 1 -179 4 -6 4 -11 -1 -11
-9 0 -8 -154 2 -170 3 -5 3 -10 -2 -10 -4 0 -6 -81 -3 -181 3 -99 2 -189 -2
-199 -4 -11 -4 -21 0 -24 8 -5 6 -80 -2 -102 -2 -6 -2 -15 1 -18 5 -5 5 -35 2
-151 0 -5 1 -52 2 -103 1 -51 -1 -95 -4 -98 -3 -3 -2 -18 3 -32 4 -15 3 -37
-1 -50 -5 -14 -5 -22 3 -22 7 0 7 -4 -1 -13 -6 -8 -9 -16 -6 -18 9 -9 12 -79
3 -79 -5 0 -6 -5 -3 -10 4 -6 7 -101 7 -213 0 -135 4 -207 12 -216 8 -12 8
-13 -2 -7 -10 6 -12 -2 -11 -33 2 -23 8 -41 13 -41 6 0 5 -4 -3 -9 -13 -8 -13
-12 0 -27 8 -10 9 -15 2 -11 -8 5 -11 -4 -10 -33 1 -22 5 -40 10 -40 4 0 5 -5
1 -12 -10 -15 -13 -68 -4 -68 3 0 6 -9 7 -20 1 -12 -3 -18 -9 -14 -6 4 -6 -1
1 -14 6 -11 11 -37 11 -56 0 -20 3 -36 8 -36 4 0 8 -10 8 -22 1 -25 11 -91 15
-95 1 -2 2 -6 3 -10 7 -37 51 -153 58 -153 4 0 8 -11 8 -25 0 -14 5 -25 10
-25 6 0 10 -9 10 -20 0 -11 5 -20 10 -20 6 0 10 -7 10 -17 0 -9 11 -31 26 -50
14 -18 21 -33 17 -33 -4 0 -3 -4 2 -8 6 -4 23 -26 39 -49 16 -24 50 -65 76
-93 26 -27 45 -50 43 -50 -2 0 11 -15 29 -34 18 -18 36 -31 40 -29 5 2 8 -3 8
-11 0 -9 5 -16 10 -16 6 0 22 -11 35 -25 14 -13 25 -22 25 -20 0 3 9 -3 19
-12 38 -35 50 -43 65 -43 7 0 17 -7 20 -16 4 -9 9 -14 12 -11 4 3 13 -1 21 -9
9 -8 22 -13 29 -10 8 3 11 0 7 -10 -3 -9 1 -14 13 -14 9 0 25 -7 34 -15 9 -8
29 -16 43 -17 15 -1 27 -5 27 -8 0 -3 15 -11 33 -18 17 -8 41 -18 52 -23 11
-6 30 -11 43 -12 12 0 22 -6 22 -11 0 -5 3 -7 6 -4 3 3 31 -1 62 -10 31 -8 68
-16 82 -16 14 -1 32 -6 40 -12 22 -13 4543 -18 4635 -4 109 16 124 19 195 39
104 29 180 59 192 76 4 5 8 7 8 2 0 -4 11 0 25 9 14 9 25 13 25 9 0 -4 7 -1
16 6 17 14 49 34 74 45 19 9 147 102 178 129 58 50 112 108 112 117 0 6 3 9 6
5 7 -7 60 54 56 65 -1 4 4 8 11 8 7 0 31 27 52 61 22 33 48 72 57 87 10 15 23
39 29 55 6 15 15 27 20 27 4 0 6 7 3 15 -4 8 -1 15 5 15 6 0 11 6 11 13 0 6 6
24 14 38 13 25 45 116 47 131 0 4 5 22 10 40 13 42 19 67 23 98 28 185 29 234
22 2430 -11 3831 -10 3729 -33 3780 -3 8 -7 22 -8 30 -1 8 -5 22 -8 30 -4 8
-8 26 -11 40 -2 14 -10 39 -16 55 -33 79 -44 104 -68 155 -36 76 -110 190
-123 190 -6 0 -9 5 -7 11 5 14 -54 78 -65 72 -4 -3 -7 -1 -6 4 3 16 -1 36 -6
31 -3 -3 -36 24 -73 58 -37 35 -71 64 -75 64 -5 0 -21 12 -36 28 -15 15 -41
34 -57 42 -16 8 -38 23 -48 33 -11 9 -25 17 -32 17 -6 0 -17 7 -24 15 -7 8
-16 12 -21 9 -5 -3 -9 0 -9 5 0 6 -9 11 -20 11 -11 0 -20 5 -20 10 0 6 -9 10
-20 10 -11 0 -20 4 -20 8 0 9 -119 52 -144 52 -9 0 -16 4 -16 8 0 7 -24 12
-67 15 -7 0 -13 3 -13 7 0 4 -6 7 -12 7 -7 1 -39 5 -70 10 -40 7 -61 7 -69 -1
-9 -8 -10 -8 -5 2 5 8 0 12 -13 12 -12 0 -21 -6 -21 -12 0 -10 -2 -10 -9 0 -5
9 -31 13 -77 13 -38 0 -181 2 -319 5 -236 5 -252 7 -285 28 -59 38 -205 100
-230 99 -3 0 -16 3 -29 8 -69 27 -260 34 -788 29 -100 -1 -187 1 -193 5 -11 7
11 35 27 35 15 0 143 141 137 151 -3 5 -1 9 4 9 11 0 52 81 52 103 0 9 5 19
11 22 6 4 8 13 5 20 -2 7 -2 16 2 19 16 16 24 183 10 202 -5 7 -8 16 -7 20 1
5 0 12 -1 17 -3 9 -4 13 -15 52 -15 58 -107 200 -138 213 -4 2 -22 17 -40 33
-17 16 -37 29 -44 29 -7 0 -13 4 -13 8 0 5 -10 13 -22 19 -13 6 -36 17 -53 24
-16 8 -36 20 -43 27 -20 18 -330 17 -357 -2z m245 -329 c34 -7 60 -19 60 -27
0 -5 9 -12 21 -15 11 -4 18 -9 15 -12 -3 -3 0 -11 7 -19 32 -35 37 -45 39 -69
1 -14 5 -25 8 -25 3 0 5 -13 5 -30 1 -16 -2 -30 -6 -30 -4 0 -6 -11 -4 -25 1
-14 -1 -25 -6 -25 -4 0 -10 -8 -14 -19 -6 -21 -55 -77 -55 -64 0 4 -4 3 -8 -3
-12 -19 -70 -44 -102 -44 -22 0 -29 -4 -24 -12 6 -10 5 -10 -8 -1 -8 7 -27 14
-42 15 -14 1 -26 6 -26 10 0 4 -6 8 -13 8 -7 0 -23 9 -34 19 -12 11 -24 17
-27 14 -3 -4 -6 1 -6 10 0 10 -4 17 -9 17 -26 0 -60 164 -38 181 4 3 10 16 12
30 3 13 9 25 15 27 6 2 9 7 7 10 -2 4 10 19 25 34 15 15 28 24 28 21 0 -3 7 0
16 8 25 21 103 28 164 16z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -0,0 +1,24 @@
{
"name": "Donetick: Simplify Tasks & Chores, Together.",
"short_name": "Donetick",
"description": "An open-source, user-friendly app for managing tasks and chores, featuring customizable options to help you and others stay organized",
"start_url": "/index.html",
"scope": "/",
"lang": "en",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -0,0 +1,18 @@
import React, { createContext, useState } from 'react'
const AuthenticationContext = createContext({})
const AuthenticationProvider = ({ children }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [userProfile, setUserProfile] = useState({})
return (
<AuthenticationContext.Provider
value={{ isLoggedIn, setIsLoggedIn, userProfile, setUserProfile }}
>
{children}
</AuthenticationContext.Provider>
)
}
export { AuthenticationContext, AuthenticationProvider }
// export default AuthenticationProvider;

250
src/utils/Fetcher.jsx Normal file
View file

@ -0,0 +1,250 @@
import { API_URL } from '../Config'
import { Fetch, HEADERS } from './TokenManager'
const createChore = userID => {
return Fetch(`${API_URL}/chores/`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
createdBy: Number(userID),
}),
}).then(response => response.json())
}
const signUp = (username, password, displayName, email) => {
return fetch(`${API_URL}/auth/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password, displayName, email }),
})
}
const login = (username, password) => {
return fetch(`${API_URL}/auth/login`, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
const GetAllUsers = () => {
return fetch(`${API_URL}/users/`, {
method: 'GET',
headers: HEADERS(),
})
}
const GetChores = () => {
return Fetch(`${API_URL}/chores/`, {
method: 'GET',
headers: HEADERS(),
})
}
const GetChoreByID = id => {
return Fetch(`${API_URL}/chores/${id}`, {
method: 'GET',
headers: HEADERS(),
})
}
const CreateChore = chore => {
return Fetch(`${API_URL}/chores/`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify(chore),
})
}
const DeleteChore = id => {
return Fetch(`${API_URL}/chores/${id}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
const SaveChore = chore => {
console.log('chore', chore)
return Fetch(`${API_URL}/chores/`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(chore),
})
}
const GetChoreHistory = choreId => {
return Fetch(`${API_URL}/chores/${choreId}/history`, {
method: 'GET',
headers: HEADERS(),
})
}
const GetAllCircleMembers = () => {
return Fetch(`${API_URL}/circles/members`, {
method: 'GET',
headers: HEADERS(),
})
}
const GetUserProfile = () => {
return Fetch(`${API_URL}/users/profile`, {
method: 'GET',
headers: HEADERS(),
})
}
const GetUserCircle = () => {
return Fetch(`${API_URL}/circles/`, {
method: 'GET',
headers: HEADERS(),
})
}
const JoinCircle = inviteCode => {
return Fetch(`${API_URL}/circles/join?invite_code=${inviteCode}`, {
method: 'POST',
headers: HEADERS(),
})
}
const GetCircleMemberRequests = () => {
return Fetch(`${API_URL}/circles/members/requests`, {
method: 'GET',
headers: HEADERS(),
})
}
const AcceptCircleMemberRequest = id => {
return Fetch(`${API_URL}/circles/members/requests/accept?requestId=${id}`, {
method: 'PUT',
headers: HEADERS(),
})
}
const LeaveCircle = id => {
return Fetch(`${API_URL}/circles/leave?circle_id=${id}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
const DeleteCircleMember = (circleID, memberID) => {
return Fetch(
`${API_URL}/circles/${circleID}/members/delete?member_id=${memberID}`,
{
method: 'DELETE',
headers: HEADERS(),
},
)
}
const UpdateUserDetails = userDetails => {
return Fetch(`${API_URL}/users`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(userDetails),
})
}
const GetSubscriptionSession = () => {
return Fetch(API_URL + `/payments/create-subscription`, {
method: 'GET',
headers: HEADERS(),
})
}
const CancelSubscription = () => {
return Fetch(API_URL + `/payments/cancel-subscription`, {
method: 'POST',
headers: HEADERS(),
})
}
const GetThings = () => {
return Fetch(`${API_URL}/things`, {
method: 'GET',
headers: HEADERS(),
})
}
const CreateThing = thing => {
return Fetch(`${API_URL}/things`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify(thing),
})
}
const SaveThing = thing => {
return Fetch(`${API_URL}/things`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(thing),
})
}
const UpdateThingState = thing => {
return Fetch(`${API_URL}/things/${thing.id}/state?value=${thing.state}`, {
method: 'PUT',
headers: HEADERS(),
})
}
const DeleteThing = id => {
return Fetch(`${API_URL}/things/${id}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
const CreateLongLiveToken = name => {
return Fetch(`${API_URL}/users/tokens`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({ name }),
})
}
const DeleteLongLiveToken = id => {
return Fetch(`${API_URL}/users/tokens/${id}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
const GetLongLiveTokens = () => {
return Fetch(`${API_URL}/users/tokens`, {
method: 'GET',
headers: HEADERS(),
})
}
export {
AcceptCircleMemberRequest,
CancelSubscription,
createChore,
CreateChore,
CreateLongLiveToken,
CreateThing,
DeleteChore,
DeleteCircleMember,
DeleteLongLiveToken,
DeleteThing,
GetAllCircleMembers,
GetAllUsers,
GetChoreByID,
GetChoreHistory,
GetChores,
GetCircleMemberRequests,
GetLongLiveTokens,
GetSubscriptionSession,
GetThings,
GetUserCircle,
GetUserProfile,
JoinCircle,
LeaveCircle,
login,
SaveChore,
SaveThing,
signUp,
UpdateThingState,
UpdateUserDetails,
}

7
src/utils/Helpers.jsx Normal file
View file

@ -0,0 +1,7 @@
import moment from 'moment'
const isPlusAccount = userProfile => {
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
}
export { isPlusAccount }

View file

@ -0,0 +1,65 @@
import Cookies from 'js-cookie'
import { API_URL } from '../Config'
export function Fetch(url, options) {
if (!isTokenValid()) {
console.log('FETCH: Token is not valid')
console.log(localStorage.getItem('ca_token'))
// store current location in cookie
Cookies.set('ca_redirect', window.location.pathname)
// Assuming you have a function isTokenValid() that checks token validity
window.location.href = '/login' // Redirect to login page
// return Promise.reject("Token is not valid");
}
if (!options) {
options = {}
}
options.headers = { ...options.headers, ...HEADERS() }
return fetch(url, options)
}
export const HEADERS = () => {
return {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + localStorage.getItem('ca_token'),
}
}
export const isTokenValid = () => {
const expiration = localStorage.getItem('ca_expiration')
const token = localStorage.getItem('ca_token')
if (localStorage.getItem('ca_token')) {
const now = new Date()
const expire = new Date(expiration)
if (now < expire) {
if (now.getTime() + 24 * 60 * 60 * 1000 > expire.getTime()) {
refreshAccessToken()
}
return true
} else {
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
}
return false
}
}
export const refreshAccessToken = () => {
fetch(API_URL + '/auth/refresh', {
method: 'GET',
headers: HEADERS(),
}).then(res => {
if (res.status === 200) {
res.json().then(data => {
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
})
} else {
return res.json().then(error => {
console.log(error)
})
}
})
}

View 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>
)
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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,
}

View 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

View 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
View 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

View 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

View 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

View 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

View 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}&nbsp;
</Typography>
)}
<Typography level='h2' component='span'>
${pi.price}
</Typography>
<Typography level='body-md' component='span'>
/ {pi.interval}
</Typography>
</Box>
<Typography level='title-md'>Features</Typography>
{pi.features.map(feature => (
<Typography
startDecorator={feature[1]}
level='body-md'
color='neutral'
lineHeight={1.6}
>
{feature[0]}
</Typography>
))}
{/* Here start the test */}
<div style={{ marginTop: 'auto' }}>
<Button
sx={{ mt: 5 }}
onClick={() => {
navigate('/settings#account')
}}
>
Get Started
</Button>
<Typography
level='body-md'
color='neutral'
lineHeight={1.6}
></Typography>
</div>
</Box>
</Card>
))}
</div>
{/* Here start the test */}
</Container>
)
}
export default PricingSection

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

View file

View 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
View 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

View 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

View 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

View file

@ -0,0 +1,11 @@
import MuiIconPicker from './IconPicker'
const TestView = () => {
return (
<div>
<MuiIconPicker />
</div>
)
}
export default TestView

View 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

View 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

View 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>
)
}

Some files were not shown because too many files have changed in this diff Show more