move to Donetick Org, First commit frontend
3
.env.development
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
13
.prettierrc
Normal 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
|
@ -0,0 +1,52 @@
|
|||

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