Compare commits

...

68 Commits

Author SHA1 Message Date
louiscklaw
5bebc1f40e ```
add COL_BILLING_ADDRESS constant and update exports
```
2025-05-13 13:28:21 +08:00
louiscklaw
f4e5f94e17 ``update .gitignore to modify exclusion pattern for _del files from '**/_del' to '**/*del'`` 2025-05-13 13:28:13 +08:00
louiscklaw
2d022cb613 ```
remove Docker Compose configuration files for CMS, Doc, Ionic Mobile, API_TS, and PocketBase services
```
2025-05-13 13:27:56 +08:00
louiscklaw
3560ea79fc "``update teacher and student seed scripts to dynamically loop through row_array and um_row_array instead of fixed iterations``" 2025-05-13 13:27:48 +08:00
louiscklaw
a441e3e52d ```
refactor teacher and user meta management UI by updating form components, replacing COL_TEACHERS with COL_USER_METAS where applicable, adding development environment checks, improving type definitions for user meta including billing address, and fixing parameter naming inconsistencies in form handlers
```
2025-05-13 13:27:41 +08:00
louiscklaw
09ded06dd2 ``update student database operations to use COL_USER_METAS instead of COL_STUDENTS, refactor getStudentById to include expanded billing address data, and add/update related types and functions for student and user meta management`` 2025-05-13 13:27:27 +08:00
louiscklaw
7ecacd0692 ```
refactor student management UI by updating create form component name and adding new edit page with translation support and form integration
```
2025-05-13 13:27:17 +08:00
louiscklaw
8a094afdd2 "``refactor student create and edit forms with translation support, update schema validation, and use new database operations for student management``" 2025-05-13 13:26:58 +08:00
louiscklaw
64ca29cf60 ``add new database operations for billing address module including create, delete, get, update functions and related type definitions`` 2025-05-13 13:26:41 +08:00
louiscklaw
1aa0502edc ``fix inconsistent quotes in code and update schema with additional fields and rules`` 2025-05-13 13:26:22 +08:00
louiscklaw
3e1f2e1057 ```
add billing address seed data and update user seed scripts with teacher and student roles
```
2025-05-13 12:35:05 +08:00
louiscklaw
9be33f641f ```
add 'visible', 'state', 'company', 'taxId', 'timezone', 'language', 'currency' fields and update 'billingAddress' and 't1' collection rules in PocketBase seed schema
```
2025-05-13 12:34:51 +08:00
louiscklaw
30e4c69343 ```
add Docker Compose configuration files for CMS, Doc, Ionic Mobile, API_TS, and PocketBase services, along with .env file for environment variables and related scripts
```
2025-05-13 06:58:49 +08:00
louiscklaw
04634b5c65 ``update backup script to change archive directory name and add script to recursively remove node_modules directories`` 2025-05-12 19:25:19 +08:00
louiscklaw
f659020d89 ``add TODO list file for Ionic mobile project`` 2025-05-12 19:24:45 +08:00
louiscklaw
184aaa1b0a ``fix HTML doctype declaration in index.html for Ionic mobile project`` 2025-05-12 19:24:39 +08:00
louiscklaw
a6170778cd ```
add new hooks for fetching QuizCRQuestions and categories, update related components to use the new hooks, and refactor SelectCategory page to use the new API
```
2025-05-12 19:24:25 +08:00
louiscklaw
650127821b ```
update nodemon config to fix watch path syntax and upgrade dependencies including sass, parcel watcher, and chokidar
```
2025-05-12 19:23:44 +08:00
louiscklaw
5440f8ea14 ``add .env to .gitignore and update ignore patterns for log, temp, and backup files`` 2025-05-12 19:23:22 +08:00
louiscklaw
f4c9dbcc34 ``add Helloworld component in Ionic mobile project`` 2025-05-12 19:23:00 +08:00
louiscklaw
f756fb8527 ``` "update tsconfig.json: adjust lib array format, simplify include syntax, and refine exclude patterns """ 2025-05-12 19:22:46 +08:00
louiscklaw
3f10a0728c ```
add authentication routes, components, and pages including AuthHome, Login, and SignUp, and implement form fields utility functions
```
2025-05-12 19:22:00 +08:00
louiscklaw
ee0aa0353b ``add development and backup scripts for Ionic mobile project, including dev script, rsync backup script, and express commit script`` 2025-05-12 19:19:39 +08:00
louiscklaw
2c7316786c ``update dc_dev.sh to remove commented logs command and adjust startup sequence for pocketbase and api_ts`` 2025-05-12 14:03:52 +08:00
louiscklaw
89f91ec2a0 ``update Notifications table schema by removing 'author' text field, adding 'author' relation, 'content', 'company', and 'link' fields, and sync changes in schema.dbml, schema.json, and pb_hooks/seed/schema.json`` 2025-05-12 14:03:35 +08:00
louiscklaw
1441863dcd ``remove obsolete UserMetas migrations and related user collection modifications`` 2025-05-12 14:01:52 +08:00
louiscklaw
1835caee68 ``update prettier config with printWidth and quoteProps, add new routes for connective revision, and update guidelines for hooks and types`` 2025-05-12 13:49:11 +08:00
louiscklaw
bac8c70d4b ``fix typos and update base URL quotes in code files`` 2025-05-12 13:48:32 +08:00
louiscklaw
975a528b49 `` update prettier config to enforce LF line endings and ES5 trailing commas `` 2025-05-12 13:29:40 +08:00
louiscklaw
749fef7e28 `` update prettier config for Ionic mobile with new endOfLine and trailingComma settings `` 2025-05-12 13:26:39 +08:00
louiscklaw
cf34833d42 init project-wise prettier config, 2025-05-12 13:22:43 +08:00
louiscklaw
7bb45316af update dependencies, 2025-05-12 13:21:28 +08:00
louiscklaw
02771185af ```
add ionic mobile workspace configuration with linked folders and git/port settings
```
2025-05-12 13:20:37 +08:00
louiscklaw
cf70e2af21 ```
refactor notifications popover to include unread count, mark all as read button, and loading state
```
2025-05-12 13:10:19 +08:00
louiscklaw
1a77c3a5e8 update helloworld template for component, 2025-05-12 11:34:06 +08:00
louiscklaw
af446aed59 update seed file, 2025-05-12 11:33:13 +08:00
louiscklaw
c7f1f544ec ```
refactor notifications popover to use new hooks and improve functionality
```
2025-05-12 11:32:53 +08:00
louiscklaw
99ee2f9fc3 ```
add admin user ID to seed config and update notification relations
```
2025-05-11 17:29:19 +08:00
louiscklaw
a4cdb4b1cc ```
add phone field to User model and update notification relations
```
2025-05-11 16:59:02 +08:00
louiscklaw
b35b77557e ```
enable prettier-plugin-sort-imports for consistent import ordering
```
2025-05-11 16:32:27 +08:00
louiscklaw
6842459499 ```
remove deprecated repomix.md file used for AI analysis
```
2025-05-11 16:32:01 +08:00
louiscklaw
ba7682e7cb "update tsconfig.json exclusion patterns for build optimization" 2025-05-11 16:31:50 +08:00
louiscklaw
1003fa699c "refactor settings pages, add translation support and implement side navigation component" 2025-05-11 16:31:32 +08:00
louiscklaw
ec12ca3bdf "make avatar mandatory and add collectionId to User interface" 2025-05-11 16:30:50 +08:00
louiscklaw
7ece1c814b update order of import, 2025-05-11 16:07:06 +08:00
louiscklaw
39a7d32fcd update, 2025-05-11 15:47:49 +08:00
louiscklaw
85d1ecdc90 update, 2025-05-11 15:44:18 +08:00
louiscklaw
b26e1ff167 update user-button, 2025-05-11 13:47:14 +08:00
louiscklaw
de415a37bc update main-nav and side-nav, 2025-05-11 13:39:33 +08:00
louiscklaw
01a8d2ca02 update main-nav, 2025-05-11 13:24:52 +08:00
louiscklaw
e5b136b8b5 update main-nav refactoring, working, 2025-05-11 13:06:58 +08:00
louiscklaw
031dbed6a9 "implement responsive main navigation bar with dropdown menus and mobile view support" 2025-05-11 13:00:30 +08:00
louiscklaw
f20dfa00c2 "add CMS workspace configuration with linked documentation and AI workspace folders" 2025-05-11 13:00:05 +08:00
louiscklaw
24c91cb6f0 "update gitignore to exclude temporary and backup files" 2025-05-11 10:48:45 +08:00
louiscklaw
abca91c26a "introduce horizontal main navigation component with dynamic styling and interactive elements" 2025-05-11 10:39:09 +08:00
louiscklaw
3321eafffa "update gitignore to exclude draft files" 2025-05-11 10:36:46 +08:00
louiscklaw
adc9275d3f "update prettier config, add TODO list, and create workspace configuration" 2025-05-11 10:35:04 +08:00
louiscklaw
60eed00cb2 "add admin user seed script and refactor common seed utilities" 2025-05-11 10:34:48 +08:00
louiscklaw
c29ab4b920 update project, 2025-05-11 08:02:50 +08:00
louiscklaw
9d46de56c3 update util scripts, 2025-05-11 08:02:07 +08:00
louiscklaw
3f9d88e733 init req0018, family photo of frameworks, 2025-05-11 08:00:31 +08:00
louiscklaw
6e576919ab init docker cofig, 2025-05-11 07:59:35 +08:00
louiscklaw
9739583f43 update ide config, remove yaml and python, 2025-05-11 07:59:11 +08:00
louiscklaw
bc1ec72df1 Update multiple markdown files with project guidelines, tasks, and database handling instructions 2025-05-11 07:57:34 +08:00
louiscklaw
e62dc5f597 "fix QuestionProgress component missing key props in button arrays," 2025-05-11 07:55:37 +08:00
louiscklaw
b5e9c8ba34 "update user popover with dynamic user metadata loading and improved UI consistency," 2025-05-11 07:55:16 +08:00
louiscklaw
9a8fd1c073 update auth guard and sign-in forms, add guidelines for custom auth to handle login and logout, 2025-05-11 07:54:23 +08:00
louiscklaw
25c1d3c917 update remove migration files in pocketbase, 2025-05-11 07:52:04 +08:00
236 changed files with 6752 additions and 34032 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=lf

7
.gitignore vendored
View File

@@ -2,8 +2,13 @@
node_modules node_modules
005_references/ 005_references/
_archive/ _archive/
_del _del
*.bak *.bak
*.log *.log
*.del *.del
**/_del **/*del
**/volumes/**
006_lab
**/*.draft

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"endOfLine": "lf",
"printWidth": 120,
"quoteProps": "consistent",
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": []
}

12
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"recommendations": [
"aflalo.dbml-formatter",
"bierner.markdown-mermaid",
"christian-kohler.path-intellisense",
"esbenp.prettier-vscode",
"humao.rest-client",
"matt-meyers.vscode-dbml",
"nicolas-liger.dbml-viewer",
"yzhang.markdown-all-in-one"
]
}

21
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"workbench.iconTheme": "material-icon-theme",
"workbench.colorTheme": "Default Dark Modern",
"editor.formatOnSave": true,
"git.ignoreLimitWarning": true
}

View File

@@ -0,0 +1,25 @@
this `tsx` file is clone from elsewhere, please understand, modify and update the content of `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/teachers/view/[id]/page.tsx.draft` to handle `Teacher` record thanks, modify comments/variables/paths/functions name please
---
please review and update all tsx files in folder `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/Users` to make it handle `user` record thanks
---
<!-- read and understand @/_AI_WORKSPACE/greetings/001_greetings.md -->
## clone source code from one type to another
please understand, modify and update the content of `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/teacher/_GUIDELINES.md` to handle `Teacher` record thanks,
modify comments/variables/paths/functions name please
---
please help to update the tsx files inside folder `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/student` to handle the `student` record
## steps
- list all `tsx` files inside directory, remember the list
- clone the original `<original>.tsx` files to `<original>.tsx.draft`
- do all your modification within `<original>.tsx.draft` files, leave `original.tsx` unchange
---

View File

@@ -0,0 +1,33 @@
Hi, i need your help.
## task
i am working on a `dbml` file
i got a `schema.json` which is exported from pocketbase
and i want to update it to my current `dbml` file (one way process for documentation usage)
## Rules
- the collection from `json` file started with `_` can be ignored. they are system collection and should not appear in `dbml`
- one collection from `json` file mapped with one table in `dbml` file
- the `presentable` field from `json` file should be ignored.
- the `id` of collection in `json` file should be jod down in the comment of `dbml` file as an reference.
- you can find the comments in `schema.dbml` contains `pb_xxx` and that is the reference to the table in `schema.json` file.
## steps
- list the collection
## information
json file: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.json`
dbml file: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.dbml`
## FAQ
1. 对于json中有但dbml中没有的表应该如何处理 添加为新表
1. 是否需要保留dbml文件中现有的注释和关系定义 完全保留
1. 字段类型映射是否有特殊规则? 沒有
1. please keep the existing comment
thanks

View File

@@ -0,0 +1,14 @@
```markdown
# Greetings
Hi,
Imagine you are a software engineer and i will send you the guideline.
plesae read it, prepare yourself and i will tell you the task afterwards
please read and understand the markdown files in directory
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/000_AI_WORKSPACE/software-engineer/greetings`,
it provides background information of project i want you to help.
thanks
```

View File

@@ -0,0 +1,32 @@
# guideline
## principles
- at any time, please keep your answer, solution, explaination simple and short (K.I.S.S. or 大道至簡)
- please divide the problem into small parts
- if you found youself cannot understand the problem, please stop and ask how to do
- if you found youself cannot solve the problem, plesae stop and ask how to do
- review the whole solution before you reply to user
- if code syntax is already there, do follow (e.g. naming convention, syntax) the existing code
- no need to explain the reason until you are told to do so
- no need to show me the code change, at the end just simple summary in point form is ok
## highlighted project directories and their meanings
- `_ignore_this_directory` please ignore this directory and any files inside it
- `001_documentation` documentation of this project
- `002_source` source code of this project
- `002_source/cms` home of Context management system of this project
- use singular form for `src/components/dashboard` (e.g. `src/components/dashboard/student`)
- use plural form for `src/app/dashboard` (e.g. `src/app/dashboard/students`)
- `002_source/ionic_mobile` home of mobile client of this project
- `002_source/pocketbase` home of pocketbase home directory this project
- `003_test` e2e test of this project (not yet implemented)
- `004_marketing` marketing page of this project (not yet implemented)
- `005_references` opensource refence of this project
- `006_lab` my test (POC) of this project
- `README.md` Readme of this project
- `TODO.md` todo list of this project
- if the directory contains `_GUIDELINES.md`, please read it before operation

View File

@@ -0,0 +1,12 @@
# Knowledgebase
you can answer the question with below knowledge:
## frameworks and stacks
- if code syntax is already there, do follow (e.g. naming convention, syntax) the existing code
- make use of MCP `Context7` when you troubleshoot the problem with below topics:
- [pocketbase javascript SDK](https://context7.com/pocketbase/js-sdk/llms.txt)
- [DBML](https://context7.com/holistics/dbml/llms.txt)
- [ionic framework](https://context7.com/ionic-team/ionic-framework/llms.txt)
- [nextjs 14 app router](https://context7.com/nextjsargentina/next.js-docs/llms.txt)

View File

@@ -0,0 +1,22 @@
# FAQ
Q: where is `dbml` file ?
A: dbml file located in `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.dbml`
Q: when file not found, do i need to search it in `_ignore_this_directory` ?
A: No, you just stop there and voice out.
Q: Shall I assume the component is already exist ?
A: yes, you can assume that
Q: Is `COL_USER_METAS` the collection for User related (e.g. `Teacher`, `Student`) collections?
A: yes
Q: Shall I verify `import` or `types` when do modification job ?
A: No, you just replace the name of the function, variables etc is ok. no need to check for dependencies thanks.
Q: how to list files with `.tsx.draft` extensions in `src/db/UserMetas` folder?
A: using command like `find src/db/UserMetas -name "*.tsx.draft" -type f -ls` to list the files with `.tsx.draft` extendions only exist in `src/db/UserMetas`
Q: when user want to modify `.tsx.draft` file, do i need to take care the `.tsx` file as well?
A: No, no don't need to, user will handle the remaining modifications. please restrict your modification in the mentioned file or directory only.

View File

@@ -0,0 +1,39 @@
# database and schemas
## getting started
Imagine there is a:
1. developer (provide the modification)
2. QA engineer (provide the feedback, and testing)
3. software engineer
4. technical writer
they will:
- conclude and integrate the ideas from developer and QA engineer
- make decision to modify the code accordingly.
## project background and initial setup
- **IMPORTANT**: No need to reply me what you are going on and your digest in this phase.
No need to show me your code plan
Just reply me "OK" when done
- base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project`
- `schema.dbml`
- read `<base_dir>/001_documentation/Requirements/REQ0006/schema.dbml`
this is file in `dbml` format stating the main database structure
- `schema.json`
- read `<base_dir>/002_source/cms/src/db/schema.json`
this is the file of current pocketbase schema
- look into the md files in folder `<base_dir>/002_source/ionic_mobile/_AI_WORKSPACE/001_guideline`
- if the directory user provided contins `_GUIDELINES.md`, please read the file
- read the files, remember and link up the ideas in file stated above, i will tell them the task afterwards
- please review at least 3 times after you modified the code

View File

@@ -0,0 +1,21 @@
please review and update all tsx files in folder `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/Users` to make it handle `user` record thanks
---
<!-- read and understand @/_AI_WORKSPACE/greetings/001_greetings.md -->
## clone source code from one type to another
please understand, modify and update the content of `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/teacher/_GUIDELINES.md` to handle `Teacher` record thanks,
modify comments/variables/paths/functions name please
---
please help to update the tsx files inside folder `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/student` to handle the `student` record
## steps
- list all `tsx` files inside directory, remember the list
- clone the original `<original>.tsx` files to `<original>.tsx.draft`
- do all your modification within `<original>.tsx.draft` files, leave `original.tsx` unchange
---

View File

@@ -0,0 +1,33 @@
Hi, i need your help.
## task
i am working on a `dbml` file
i got a `schema.json` which is exported from pocketbase
and i want to update it to my current `dbml` file (one way process for documentation usage)
## Rules
- the collection from `json` file started with `_` can be ignored. they are system collection and should not appear in `dbml`
- one collection from `json` file mapped with one table in `dbml` file
- the `presentable` field from `json` file should be ignored.
- the `id` of collection in `json` file should be jod down in the comment of `dbml` file as an reference.
- you can find the comments in `schema.dbml` contains `pb_xxx` and that is the reference to the table in `schema.json` file.
## steps
- list the collection
## information
json file: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.json`
dbml file: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.dbml`
## FAQ
1. 对于json中有但dbml中没有的表应该如何处理 添加为新表
1. 是否需要保留dbml文件中现有的注释和关系定义 完全保留
1. 字段类型映射是否有特殊规则? 沒有
1. please keep the existing comment
thanks

View File

@@ -0,0 +1,8 @@
Hi, i will send you the guideline,
plesae read it, prepare yourself and i will tell you the task afterwards
please read and understand the markdown files in directory
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/000_AI_WORKSPACE/_AI_WORKSPACE/greetings`,
it provides background information of project i want you to help.
thanks

View File

@@ -0,0 +1,28 @@
# guideline
## principles
- at any time, please keep your answer, solution, explaination simple and short (K.I.S.S. or 大道至簡)
- please divide the problem into small parts
- if you found youself cannot understand the problem, please stop and ask how to do
- if you found youself cannot solve the problem, plesae stop and ask how to do
- review the whole solution before you reply to user
- if code syntax is already there, do follow (e.g. naming convention, syntax) the existing code
- no need to explain the reason until you are told to do so
- no need to show me the code change, at the end just simple summary in point form is ok
## highlighted project directories and their meanings
- `_ignore_this_directory` please ignore this directory and any files inside it
- `001_documentation` documentation of this project
- `002_source` source code of this project
- `002_source/cms` home of Context management system of this project
- `002_source/ionic_mobile` home of mobile client of this project
- `002_source/pocketbase` home of pocketbase home directory this project
- `003_test` e2e test of this project (not yet implemented)
- `004_marketing` marketing page of this project (not yet implemented)
- `005_references` opensource refence of this project
- `006_lab` my test (POC) of this project
- `README.md` Readme of this project
- `TODO.md` todo list of this project

View File

@@ -0,0 +1,12 @@
# Knowledgebase
you can answer the question with below knowledge:
## frameworks and stacks
- if code syntax is already there, do follow (e.g. naming convention, syntax) the existing code
- make use of MCP `Context7` when you troubleshoot the problem with below topics:
- [pocketbase javascript SDK](https://context7.com/pocketbase/js-sdk/llms.txt)
- [DBML](https://context7.com/holistics/dbml/llms.txt)
- [ionic framework](https://context7.com/ionic-team/ionic-framework/llms.txt)
- [nextjs 14 app router](https://context7.com/nextjsargentina/next.js-docs/llms.txt)

View File

@@ -0,0 +1,7 @@
# FAQ
Q: where is `dbml` file ?
A: dbml file located in `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/001_documentation/Requirements/REQ0006/schema.dbml`
Q: when file not found, do i need to search it in `_ignore_this_directory` ?
A: No, you just stop there and voice out.

View File

@@ -0,0 +1,48 @@
# database and schemas
## getting started
Imagine there is a:
1. developer (provide the modification)
2. QA engineer (provide the feedback, and testing)
3. software engineer
4. technical writer
they will:
- conclude and integrate the ideas from developer and QA engineer
- make decision to modify the code accordingly.
## project background and initial setup
- **IMPORTANT**: No need to reply me what you are going on and your digest in this phase.
No need to show me your code plan
Just reply me "OK" when done
- base_dir=`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project`
- `schema.dbml`
- read `<base_dir>/001_documentation/Requirements/REQ0006/schema.dbml`
this is file in `dbml` format stating the main database structure
- `schema.json`
- read `<base_dir>/002_source/cms/src/db/schema.json`
this is the file of current pocketbase schema
- look into the md files in folder `<base_dir>/002_source/ionic_mobile/_AI_WORKSPACE/001_guideline`
- if the directory user provided contins `_GUIDELINES.md`, please read the file
- read the files, remember and link up the ideas in file stated above, i will tell them the task afterwards
- please review at least 3 times after you modified the code
## frameworks documentation and samples
- react
- ionic and capacitor
- pocketbase
- tanstack/react-query
- vite
- typescript

View File

@@ -1,6 +1,5 @@
{ {
"recommendations": [ "recommendations": [
"redhat.vscode-yaml",
"yzhang.markdown-all-in-one", "yzhang.markdown-all-in-one",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"ms-python.python", "ms-python.python",
@@ -8,7 +7,6 @@
"ms-python.debugpy", "ms-python.debugpy",
"ms-python.black-formatter", "ms-python.black-formatter",
"ms-python.isort", "ms-python.isort",
"ms-python.pylint",
"bierner.markdown-mermaid", "bierner.markdown-mermaid",
"shd101wyy.markdown-preview-enhanced", "shd101wyy.markdown-preview-enhanced",
"yzhang.markdown-all-in-one", "yzhang.markdown-all-in-one",

View File

@@ -1,11 +1,11 @@
import { faker } from "@faker-js/faker"; import { faker } from '@faker-js/faker';
const getId = (id) => id.padStart(15, "0"); const getId = (id) => id.padStart(15, '0');
const row_array = Array.from({ length: 10 }, (_, i) => [ const row_array = Array.from({ length: 10 }, (_, i) => [
getId(String(i + 1)), getId(String(i + 1)),
faker.person.firstName(), faker.person.firstName(),
"", '',
faker.internet.email(), faker.internet.email(),
faker.phone.number(), faker.phone.number(),
faker.company.name(), faker.company.name(),
@@ -19,13 +19,11 @@ const row_array = Array.from({ length: 10 }, (_, i) => [
}, },
Math.floor(Math.random() * (100 - 0 + 1)) + 0, Math.floor(Math.random() * (100 - 0 + 1)) + 0,
faker.location.timeZone(), faker.location.timeZone(),
["en", "de", "es", "fr", "ja", "ko", "zh-CN"].sort( ['en', 'de', 'es', 'fr', 'ja', 'ko', 'zh-CN'].sort(() => Math.random() - 0.5)[0],
() => Math.random() - 0.5
)[0],
faker.finance.currencyCode(), faker.finance.currencyCode(),
]); ]);
import fs from "fs"; import fs from 'fs';
const filePath = "output.json"; const filePath = 'output.json';
fs.writeFileSync(filePath, JSON.stringify(row_array, null, 2)); fs.writeFileSync(filePath, JSON.stringify(row_array, null, 2));
console.log(`Wrote ${row_array.length} records to ${filePath}`); console.log(`Wrote ${row_array.length} records to ${filePath}`);

View File

@@ -1,4 +1,4 @@
// Generated at: 2025-05-08T05:00:49.862Z // Generated at: 2025-05-13T05:24:33.962Z
// //
@@ -32,6 +32,7 @@ Table users {
created datetime created datetime
updated datetime updated datetime
visible text visible text
phone text
} }
// //
@@ -113,12 +114,17 @@ Table Notifications {
id text [pk, not null] id text [pk, not null]
read boolean read boolean
type text type text
author text
job text job text
description text description text
NOTI_ID text NOTI_ID text
created datetime created datetime
updated datetime updated datetime
to_user_id integer [ref: > UserMetas.id] // relation704048736
from_user_id integer [ref: > UserMetas.id] // relation556806202
author integer [ref: > UserMetas.id] // relation3182418120
content text
company text
link varchar
} }
// //
@@ -200,9 +206,9 @@ Table QuizLPCategories {
cat_image file cat_image file
pos integer pos integer
init_answer text init_answer text
visible text
created datetime created datetime
updated datetime updated datetime
visible text
slug text slug text
remarks text remarks text
description text description text
@@ -217,8 +223,6 @@ Table QuizLPQuestions {
word text word text
sound file sound file
cat_id integer [ref: > QuizLPCategories.id] // relation3870140739 cat_id integer [ref: > QuizLPCategories.id] // relation3870140739
created datetime
updated datetime
cat_name text cat_name text
cat_image file cat_image file
pos integer pos integer
@@ -227,6 +231,8 @@ Table QuizLPQuestions {
slug text slug text
remarks text remarks text
description text description text
created datetime
updated datetime
} }
// //
@@ -252,9 +258,9 @@ Table QuizMFCategories {
cat_image file cat_image file
pos integer pos integer
init_answer text init_answer text
visible text
created datetime created datetime
updated datetime updated datetime
visible text
} }
// //
@@ -335,18 +341,23 @@ Table Teachers {
// collection type: base // collection type: base
Table UserMetas { Table UserMetas {
id text [pk, not null] id text [pk, not null]
helloworld text address text
meta text meta text
user_id integer [ref: > users.id] // relation2809058197 user_id integer [ref: > users.id] // relation2809058197
state text
created datetime created datetime
updated datetime updated datetime
status text
avatar file avatar file
role text role text
name text name text
email text email text
phone text phone text
avatar_file file company text
taxId text
timezone text
language text
currency text
billingAddress integer [ref: > billingAddress.id] // relation2115670734
} }
// //

View File

@@ -318,6 +318,20 @@
"required": false, "required": false,
"system": false, "system": false,
"type": "text" "type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1146066909",
"max": 0,
"min": 0,
"name": "phone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
} }
], ],
"indexes": [ "indexes": [
@@ -1043,16 +1057,6 @@
"system": false, "system": false,
"type": "text" "type": "text"
}, },
{
"hidden": false,
"id": "json3182418120",
"maxSize": 0,
"name": "author",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{ {
"hidden": false, "hidden": false,
"id": "json4225294584", "id": "json4225294584",
@@ -1110,6 +1114,77 @@
"presentable": false, "presentable": false,
"system": false, "system": false,
"type": "autodate" "type": "autodate"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1305841361",
"hidden": false,
"id": "relation704048736",
"maxSelect": 1,
"minSelect": 0,
"name": "to_user_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1305841361",
"hidden": false,
"id": "relation556806202",
"maxSelect": 1,
"minSelect": 0,
"name": "from_user_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1305841361",
"hidden": false,
"id": "relation3182418120",
"maxSelect": 1,
"minSelect": 0,
"name": "author",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"convertURLs": false,
"hidden": false,
"id": "editor4274335913",
"maxSize": 0,
"name": "content",
"presentable": false,
"required": false,
"system": false,
"type": "editor"
},
{
"hidden": false,
"id": "json1337919823",
"maxSize": 0,
"name": "company",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"exceptDomains": null,
"hidden": false,
"id": "url917281265",
"name": "link",
"onlyDomains": null,
"presentable": false,
"required": false,
"system": false,
"type": "url"
} }
], ],
"indexes": [], "indexes": [],
@@ -1675,6 +1750,20 @@
"system": false, "system": false,
"type": "json" "type": "json"
}, },
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2058414169",
"max": 0,
"min": 0,
"name": "visible",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{ {
"hidden": false, "hidden": false,
"id": "autodate2990389176", "id": "autodate2990389176",
@@ -1695,20 +1784,6 @@
"system": false, "system": false,
"type": "autodate" "type": "autodate"
}, },
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2058414169",
"max": 0,
"min": 0,
"name": "visible",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{ {
"autogeneratePattern": "", "autogeneratePattern": "",
"hidden": false, "hidden": false,
@@ -1817,26 +1892,6 @@
"system": false, "system": false,
"type": "relation" "type": "relation"
}, },
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
},
{ {
"autogeneratePattern": "", "autogeneratePattern": "",
"hidden": false, "hidden": false,
@@ -1939,6 +1994,26 @@
"required": false, "required": false,
"system": false, "system": false,
"type": "editor" "type": "editor"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
} }
], ],
"indexes": [], "indexes": [],
@@ -2107,6 +2182,20 @@
"system": false, "system": false,
"type": "json" "type": "json"
}, },
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2058414169",
"max": 0,
"min": 0,
"name": "visible",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{ {
"hidden": false, "hidden": false,
"id": "autodate2990389176", "id": "autodate2990389176",
@@ -2126,20 +2215,6 @@
"presentable": false, "presentable": false,
"system": false, "system": false,
"type": "autodate" "type": "autodate"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2058414169",
"max": 0,
"min": 0,
"name": "visible",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
} }
], ],
"indexes": [], "indexes": [],
@@ -2795,7 +2870,7 @@
"id": "text4192936109", "id": "text4192936109",
"max": 0, "max": 0,
"min": 0, "min": 0,
"name": "helloworld", "name": "address",
"pattern": "", "pattern": "",
"presentable": false, "presentable": false,
"primaryKey": false, "primaryKey": false,
@@ -2826,6 +2901,20 @@
"system": false, "system": false,
"type": "relation" "type": "relation"
}, },
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2744374011",
"max": 0,
"min": 0,
"name": "state",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{ {
"hidden": false, "hidden": false,
"id": "autodate2990389176", "id": "autodate2990389176",
@@ -2846,20 +2935,6 @@
"system": false, "system": false,
"type": "autodate" "type": "autodate"
}, },
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2744374011",
"max": 0,
"min": 0,
"name": "status",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{ {
"hidden": false, "hidden": false,
"id": "file376926767", "id": "file376926767",
@@ -2903,18 +2978,15 @@
"type": "text" "type": "text"
}, },
{ {
"autogeneratePattern": "", "exceptDomains": null,
"hidden": false, "hidden": false,
"id": "text3885137012", "id": "email3885137012",
"max": 0,
"min": 0,
"name": "email", "name": "email",
"pattern": "", "onlyDomains": null,
"presentable": false, "presentable": false,
"primaryKey": false,
"required": false, "required": false,
"system": false, "system": false,
"type": "text" "type": "email"
}, },
{ {
"autogeneratePattern": "", "autogeneratePattern": "",
@@ -2931,18 +3003,87 @@
"type": "text" "type": "text"
}, },
{ {
"autogeneratePattern": "",
"hidden": false, "hidden": false,
"id": "file507207115", "id": "text1337919823",
"maxSelect": 1, "max": 0,
"maxSize": 0, "min": 0,
"mimeTypes": [], "name": "company",
"name": "avatar_file", "pattern": "",
"presentable": false, "presentable": false,
"protected": false, "primaryKey": false,
"required": false, "required": false,
"system": false, "system": false,
"thumbs": [], "type": "text"
"type": "file" },
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2020362641",
"max": 0,
"min": 0,
"name": "taxId",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text922858135",
"max": 0,
"min": 0,
"name": "timezone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3571151285",
"max": 0,
"min": 0,
"name": "language",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1767278655",
"max": 0,
"min": 0,
"name": "currency",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1509025625",
"hidden": false,
"id": "relation2115670734",
"maxSelect": 999,
"minSelect": 0,
"name": "billingAddress",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
} }
], ],
"indexes": [], "indexes": [],
@@ -3546,11 +3687,11 @@
}, },
{ {
"id": "pbc_1509025625", "id": "pbc_1509025625",
"listRule": null, "listRule": "",
"viewRule": null, "viewRule": "",
"createRule": null, "createRule": "",
"updateRule": null, "updateRule": "",
"deleteRule": null, "deleteRule": "",
"name": "billingAddress", "name": "billingAddress",
"type": "base", "type": "base",
"fields": [ "fields": [
@@ -3740,11 +3881,11 @@
}, },
{ {
"id": "pbc_2109205374", "id": "pbc_2109205374",
"listRule": null, "listRule": "",
"viewRule": null, "viewRule": "",
"createRule": null, "createRule": "",
"updateRule": null, "updateRule": "",
"deleteRule": null, "deleteRule": "",
"name": "t1", "name": "t1",
"type": "base", "type": "base",
"fields": [ "fields": [

View File

@@ -0,0 +1,7 @@
---
tags: mobile, cms, db
---
# family photo of frameworks
it should have a family photo of used framework

14
002_source/.env Normal file
View File

@@ -0,0 +1,14 @@
# THIS IS env file for use with docker-compose.yml
# cms
# doc
# ionic_mobile
# api_ts
# pocketbase
PB_HOSTNAME=pocketbase
PB_USERNAME=admin@123.com
PB_PASSWORD=Aa12345678

4
002_source/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
**/*.del
**/*.log
**/_archive
**/_del

74
002_source/README.md Normal file
View File

@@ -0,0 +1,74 @@
# README
## to start production
```bash
# start docker before hand
$ ./dc_build.sh
$ ./dc_up.sh
```
## to start develop
```bash
#
$ ./dc_build.sh
$ ./dc_dev.sh
$ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d pocketbase api_ts --remove-orphans
$ docker compose logs -f pocketbase api_ts
```
## test api endpoint
```bash
# browse to http://localhost:3000/helloworld
```
```bash
# start docker before hand
$ cd 002_source
$ ./dev.sh
# docker containers up
```
---
## deprecated
```bash
# mobile
$ cd 002_source/mobile
$ pnpm run dev
# cms
$ cd 002_source/cms
$ npm run dev
```
ideation
prototyping
testing
production deployment
evaluation
monitoring
## addresses
```
mobile:
http://localhost:5173
pocketbase:
http://localhost:8090/_
cms:
http://localhost:3000
documentation
http://localhost:3001
```
extend to vocabularies

View File

@@ -28,9 +28,7 @@ const config = {
'', '',
'^[./]', '^[./]',
], ],
plugins: [ plugins: ['@ianvs/prettier-plugin-sort-imports'],
// '@ianvs/prettier-plugin-sort-imports'
],
overrides: [ overrides: [
{ {
files: ['*.tsx'], files: ['*.tsx'],

File diff suppressed because it is too large Load Diff

View File

@@ -1,558 +0,0 @@
This file is a merged representation of the entire codebase, combined into a single document by Repomix.
<file_summary>
This section contains a summary of this file.
<purpose>
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
</purpose>
<file_format>
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files, each consisting of:
- File path as an attribute
- Full contents of the file
</file_format>
<usage_guidelines>
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
</usage_guidelines>
<notes>
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Files are sorted by Git change count (files with more changes are at the bottom)
</notes>
<additional_info>
</additional_info>
</file_summary>
<directory_structure>
AddressCard/
index.tsx
SampleAddresses.tsx
BasicDetailCard/
index.tsx
Notifications/
index.tsx
type.d.ts
PaymentCard/
index.tsx
SamplePayments.tsx
SecurityCard/
index.tsx
TitleCard/
index.tsx
Helloworld.tsx
</directory_structure>
<files>
This section contains the contents of the repository's files.
<file path="AddressCard/index.tsx">
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import Grid from '@mui/material/Unstable_Grid2';
import { House as HouseIcon } from '@phosphor-icons/react/dist/ssr/House';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { useTranslation } from 'react-i18next';
import type { Address } from '@/types/Address';
import { ShippingAddress } from '@/components/dashboard/lp/categories/shipping-address';
import { SampleAddresses } from './SampleAddresses';
export default function SampleAddressCard(): React.JSX.Element {
const { t } = useTranslation();
return (
<Card>
<CardHeader
action={
<Button
color="secondary"
startIcon={<PlusIcon />}
>
{t('list.add')}
</Button>
}
avatar={
<Avatar>
<HouseIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title={t('list.shipping-addresses')}
/>
<CardContent>
<Grid
container
spacing={3}
>
{(SampleAddresses satisfies Address[]).map((address) => (
<Grid
key={address.id}
md={6}
xs={12}
>
<ShippingAddress address={address} />
</Grid>
))}
</Grid>
</CardContent>
</Card>
);
}
</file>
<file path="AddressCard/SampleAddresses.tsx">
'use client';
import type { Address } from '@/types/Address';
export const SampleAddresses: Address[] = [
{
id: 'ADR-001',
country: 'United States',
state: 'Michigan',
city: 'Lansing',
zipCode: '48933',
street: '480 Haven Lane',
primary: true,
},
{
id: 'ADR-002',
country: 'United States',
state: 'Missouri',
city: 'Springfield',
zipCode: '65804',
street: '4807 Lighthouse Drive',
},
];
</file>
<file path="BasicDetailCard/index.tsx">
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Avatar from '@mui/material/Avatar';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
import { useTranslation } from 'react-i18next';
import { PropertyItem } from '@/components/core/property-item';
import { PropertyList } from '@/components/core/property-list';
export default function BasicDetailCard({
lpCatId,
handleEditClick,
}: {
lpCatId: string;
handleEditClick: () => void;
}): React.JSX.Element {
const { t } = useTranslation();
return (
<Card>
<CardHeader
action={
<IconButton
onClick={() => {
handleEditClick();
}}
>
<PencilSimpleIcon />
</IconButton>
}
avatar={
<Avatar>
<UserIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title={t('list.basic-details')}
/>
<PropertyList
divider={<Divider />}
orientation="vertical"
sx={{ '--PropertyItem-padding': '12px 24px' }}
>
{(
[
{
key: 'Customer ID',
value: (
<Chip
label="USR-001"
size="small"
variant="soft"
/>
),
},
{ key: 'Name', value: 'Miron Vitold' },
{ key: 'Email', value: 'miron.vitold@domain.com' },
{ key: 'Phone', value: '(425) 434-5535' },
{ key: 'Company', value: 'Devias IO' },
{
key: 'Quota',
value: (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<LinearProgress
sx={{ flex: '1 1 auto' }}
value={50}
variant="determinate"
/>
<Typography
color="text.secondary"
variant="body2"
>
50%
</Typography>
</Stack>
),
},
] satisfies { key: string; value: React.ReactNode }[]
).map(
(item): React.JSX.Element => (
<PropertyItem
key={item.key}
name={item.key}
value={item.value}
/>
)
)}
</PropertyList>
</Card>
);
}
</file>
<file path="Notifications/index.tsx">
'use client';
import { dayjs } from '@/lib/dayjs';
import type { Notification } from './type';
export const SampleNotifications: Notification[] = [
{
id: 'EV-002',
type: 'Refund request approved',
status: 'pending',
createdAt: dayjs().subtract(34, 'minute').subtract(5, 'hour').subtract(3, 'day').toDate(),
},
{
id: 'EV-001',
type: 'Order confirmation',
status: 'delivered',
createdAt: dayjs().subtract(49, 'minute').subtract(11, 'hour').subtract(4, 'day').toDate(),
},
];
</file>
<file path="Notifications/type.d.ts">
export interface Notification {
id: string;
type: string;
status: 'delivered' | 'pending' | 'failed';
createdAt: Date;
}
</file>
<file path="PaymentCard/index.tsx">
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import Divider from '@mui/material/Divider';
import { CreditCard as CreditCardIcon } from '@phosphor-icons/react/dist/ssr/CreditCard';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { useTranslation } from 'react-i18next';
import { PropertyItem } from '@/components/core/property-item';
import { PropertyList } from '@/components/core/property-list';
import { Payments } from '@/components/dashboard/lp/categories/payments';
import { SamplePayments } from './SamplePayments';
export default function SamplePaymentCard(): React.JSX.Element {
const { t } = useTranslation();
return (
<>
<Payments
ordersValue={2069.48}
payments={SamplePayments}
refundsValue={324.5}
totalOrders={5}
/>
<Card>
<CardHeader
action={
<Button
color="secondary"
startIcon={<PencilSimpleIcon />}
>
{t('list.edit')}
</Button>
}
avatar={
<Avatar>
<CreditCardIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title={t('list.billing-details')}
/>
<CardContent>
<Card
sx={{ borderRadius: 1 }}
variant="outlined"
>
<PropertyList
divider={<Divider />}
sx={{ '--PropertyItem-padding': '16px' }}
>
{(
[
{ key: t('Credit card'), value: '**** 4142' },
{ key: t('Country'), value: t('United States') },
{ key: t('State'), value: t('Michigan') },
{ key: t('City'), value: t('Southfield') },
{ key: t('Address'), value: t('Address') },
{ key: t('Tax ID'), value: t('Tax ID') },
] satisfies { key: string; value: React.ReactNode }[]
).map(
(item): React.JSX.Element => (
<PropertyItem
key={item.key}
name={item.key}
value={item.value}
/>
)
)}
</PropertyList>
</Card>
</CardContent>
</Card>
</>
);
}
</file>
<file path="PaymentCard/SamplePayments.tsx">
'use client';
// import { dayjs } from 'dayjs';
import type { Payment } from '@/types/Payment';
import { dayjs } from '@/lib/dayjs';
export const SamplePayments: Payment[] = [
{
currency: 'USD',
amount: 500,
invoiceId: 'INV-005',
status: 'completed',
createdAt: dayjs().subtract(5, 'minute').subtract(1, 'hour').toDate(),
},
{
currency: 'USD',
amount: 324.5,
invoiceId: 'INV-004',
status: 'refunded',
createdAt: dayjs().subtract(21, 'minute').subtract(2, 'hour').toDate(),
},
{
currency: 'USD',
amount: 746.5,
invoiceId: 'INV-003',
status: 'completed',
createdAt: dayjs().subtract(7, 'minute').subtract(3, 'hour').toDate(),
},
{
currency: 'USD',
amount: 56.89,
invoiceId: 'INV-002',
status: 'completed',
createdAt: dayjs().subtract(48, 'minute').subtract(4, 'hour').toDate(),
},
{
currency: 'USD',
amount: 541.59,
invoiceId: 'INV-001',
status: 'completed',
createdAt: dayjs().subtract(31, 'minute').subtract(5, 'hour').toDate(),
},
];
</file>
<file path="SecurityCard/index.tsx">
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ShieldWarning as ShieldWarningIcon } from '@phosphor-icons/react/dist/ssr/ShieldWarning';
import { useTranslation } from 'react-i18next';
export default function SampleSecurityCard(): React.JSX.Element {
const { t } = useTranslation();
return (
<Card>
<CardHeader
avatar={
<Avatar>
<ShieldWarningIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title={t('list.security')}
/>
<CardContent>
<Stack spacing={1}>
<div>
<Button
color="error"
variant="contained"
>
{t('Delete account')}
</Button>
</div>
<Typography
color="text.secondary"
variant="body2"
>
{t('a-deleted-customer-cannot-be-restored-all-data-will-be-permanently-removed')}
</Typography>
</Stack>
</CardContent>
</Card>
);
}
</file>
<file path="TitleCard/index.tsx">
'use client';
import * as React from 'react';
import { Button } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
import { useTranslation } from 'react-i18next';
export default function SampleTitleCard(): React.JSX.Element {
const { t } = useTranslation();
return (
<>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto' }}
>
<Avatar
src="/assets/avatar-1.png"
sx={{ '--Avatar-size': '64px' }}
>
empty
</Avatar>
<div>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="h4">{t('list.customer-name')}</Typography>
<Chip
icon={
<CheckCircleIcon
color="var(--mui-palette-success-main)"
weight="fill"
/>
}
label={t('list.active')}
size="small"
variant="outlined"
/>
</Stack>
<Typography
color="text.secondary"
variant="body1"
>
{t('list.customer-email')}
</Typography>
</div>
</Stack>
<div>
<Button
endIcon={<CaretDownIcon />}
variant="contained"
>
{t('list.action')}
</Button>
</div>
</>
);
}
</file>
<file path="Helloworld.tsx">
'use client';
import * as React from 'react';
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
function Page(): React.JSX.Element {
React.useLayoutEffect(() => {
console.log('helloworld');
}, []);
return <>helloworld</>;
}
export default Page;
</file>
</files>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
'use client';
import * as React from 'react'; import * as React from 'react';
import type { Metadata } from 'next'; // import type { Metadata } from 'next';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { config } from '@/config'; import { config } from '@/config';
import { AccountDetails } from '@/components/dashboard/settings/account-details'; import { AccountDetails } from '@/components/dashboard/settings/account-details';
@@ -9,13 +12,15 @@ import { DeleteAccount } from '@/components/dashboard/settings/delete-account';
import { Privacy } from '@/components/dashboard/settings/privacy'; import { Privacy } from '@/components/dashboard/settings/privacy';
import { ThemeSwitch } from '@/components/dashboard/settings/theme-switch'; import { ThemeSwitch } from '@/components/dashboard/settings/theme-switch';
export const metadata = { title: `Account | Settings | Dashboard | ${config.site.name}` } satisfies Metadata; // export const metadata = { title: `Account | Settings | Dashboard | ${config.site.name}` } satisfies Metadata;
export default function Page(): React.JSX.Element { export default function Page(): React.JSX.Element {
const { t } = useTranslation();
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<div> <div>
<Typography variant="h4">Account</Typography> <Typography variant="h4">{t('account')}</Typography>
</div> </div>
<Stack spacing={4}> <Stack spacing={4}>
<AccountDetails /> <AccountDetails />

View File

@@ -18,7 +18,11 @@ export default function Layout({ children }: LayoutProps): React.JSX.Element {
width: 'var(--Content-width)', width: 'var(--Content-width)',
}} }}
> >
<Stack direction={{ xs: 'column', md: 'row' }} spacing={4} sx={{ position: 'relative' }}> <Stack
direction={{ xs: 'column', md: 'row' }}
spacing={4}
sx={{ position: 'relative' }}
>
<SideNav /> <SideNav />
<Box sx={{ flex: '1 1 auto', minWidth: 0 }}>{children}</Box> <Box sx={{ flex: '1 1 auto', minWidth: 0 }}>{children}</Box>
</Stack> </Stack>

View File

@@ -14,10 +14,11 @@ import { PhoneNotifications } from '@/components/dashboard/settings/phone-notifi
export default function Page(): React.JSX.Element { export default function Page(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<div> <div>
<Typography variant="h4">{t('Notifications')}</Typography> <Typography variant="h4">{t('notifications')}</Typography>
</div> </div>
<Stack spacing={4}> <Stack spacing={4}>
<EmailNotifications /> <EmailNotifications />

View File

@@ -1,7 +1,10 @@
'use client';
import * as React from 'react'; import * as React from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { config } from '@/config'; import { config } from '@/config';
import { dayjs } from '@/lib/dayjs'; import { dayjs } from '@/lib/dayjs';
@@ -9,13 +12,15 @@ import { LoginHistory } from '@/components/dashboard/settings/login-history';
import { MultiFactor } from '@/components/dashboard/settings/multi-factor'; import { MultiFactor } from '@/components/dashboard/settings/multi-factor';
import { PasswordForm } from '@/components/dashboard/settings/password-form'; import { PasswordForm } from '@/components/dashboard/settings/password-form';
export const metadata = { title: `Security | Settings | Dashboard | ${config.site.name}` } satisfies Metadata; // export const metadata = { title: `Security | Settings | Dashboard | ${config.site.name}` } satisfies Metadata;
export default function Page(): React.JSX.Element { export default function Page(): React.JSX.Element {
const { t } = useTranslation();
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<div> <div>
<Typography variant="h4">Security</Typography> <Typography variant="h4">{t('security')}</Typography>
</div> </div>
<Stack spacing={4}> <Stack spacing={4}>
<PasswordForm /> <PasswordForm />

View File

@@ -1,33 +1,38 @@
'use client';
import * as React from 'react'; import * as React from 'react';
import type { Metadata } from 'next'; // import type { Metadata } from 'next';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { config } from '@/config'; import { config } from '@/config';
import { Members } from '@/components/dashboard/settings/members'; import { Members } from '@/components/dashboard/settings/members';
export const metadata = { title: `Team | Settings | Dashboard | ${config.site.name}` } satisfies Metadata; // export const metadata = { title: `Team | Settings | Dashboard | ${config.site.name}` } satisfies Metadata;
export default function Page(): React.JSX.Element { export default function Page(): React.JSX.Element {
const { t } = useTranslation();
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<div> <div>
<Typography variant="h4">Team</Typography> <Typography variant="h4">{t('team')}</Typography>
</div> </div>
<Members <Members
members={[ members={[
{ {
id: 'USR-000', id: 'USR-000',
name: 'Sofia Rivers', name: 'team member1',
avatar: '/assets/avatar.png', avatar: '/assets/avatar.png',
email: 'sofia@devias.io', email: 'teamMember1@devias.io',
role: 'Owner', role: 'Owner',
}, },
{ {
id: 'USR-002', id: 'USR-002',
name: 'Siegbert Gottfried', name: 'team member2',
avatar: '/assets/avatar-2.png', avatar: '/assets/avatar-2.png',
email: 'siegbert.gottfried@domain.com', email: 'teamMember2@domain.com',
role: 'Standard', role: 'Standard',
}, },
]} ]}

View File

@@ -9,7 +9,7 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { config } from '@/config'; import { config } from '@/config';
import { paths } from '@/paths'; import { paths } from '@/paths';
import { CustomerCreateForm } from '@/components/dashboard/student/student-create-form'; import { StudentCreateForm } from '@/components/dashboard/student/student-create-form';
export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
@@ -41,7 +41,7 @@ export default function Page(): React.JSX.Element {
<Typography variant="h4">Create customer</Typography> <Typography variant="h4">Create customer</Typography>
</div> </div>
</Stack> </Stack>
<CustomerCreateForm /> <StudentCreateForm />
</Stack> </Stack>
</Box> </Box>
); );

View File

@@ -1 +0,0 @@
this `tsx` file is clone from elsewhere, please understand, modify and update the content of `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/students/edit/[customerId]/page.tsx.draft` to handle `Student` record thanks, modify comments/variables/paths/functions name please

View File

@@ -0,0 +1,6 @@
this `tsx` file is clone from elsewhere,
please understand, modify and update the content of
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/students/edit/[customerId]/page.tsx.draft`
to handle `Student` record thanks,
modify comments/variables/paths/functions name please

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
// src/app/dashboard/students/edit/[customerId]/page.tsx
// src/app/dashboard/students/edit/[customerId]/page.tsx
import * as React from 'react'; import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
@@ -11,7 +11,8 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { paths } from '@/paths'; import { paths } from '@/paths';
import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form'; // TODO: remove me
// import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
import { StudentEditForm } from '@/components/dashboard/student/student-edit-form'; import { StudentEditForm } from '@/components/dashboard/student/student-edit-form';
export default function Page(): React.JSX.Element { export default function Page(): React.JSX.Element {

View File

@@ -6,6 +6,9 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import { Box } from '@mui/system'; import { Box } from '@mui/system';
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash'; import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
import { logger } from '@/lib/default-logger';
import { toast } from '@/components/core/toaster';
interface PropsHelloworld { interface PropsHelloworld {
message: string; message: string;
} }
@@ -33,11 +36,21 @@ function InnerComponent(): React.JSX.Element {
// RULES: sample of main component // RULES: sample of main component
function MainComponent(): React.JSX.Element { function MainComponent(): React.JSX.Element {
const [state, setState] = React.useState<string>(''); const [state, setState] = React.useState<string>('');
const [loading, setLoading] = React.useState(true);
const [showError, setShowError] = React.useState<boolean>(false);
React.useEffect(() => { React.useEffect(() => {
setState(funcHelloworld('hello')); try {
setState(funcHelloworld('hello'));
} catch (error) {
setShowError(true);
}
setLoading(false);
}, []); }, []);
if (loading) return <>Loading</>;
if (showError) return <>Error</>;
// you should obey react/jsx-no-useless-fragment // you should obey react/jsx-no-useless-fragment
return ( return (
<Box> <Box>

View File

@@ -29,6 +29,7 @@ export function AuthGuard({ children }: AuthGuardProps): React.JSX.Element | nul
return; return;
} }
// NOTE: here state that if user = null, eject user to login page
if (!user) { if (!user) {
logger.debug('[AuthGuard]: User is not logged in, redirecting to sign in'); logger.debug('[AuthGuard]: User is not logged in, redirecting to sign in');

View File

@@ -0,0 +1,10 @@
# GUIDELINES
This folder contains login pages
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller

View File

@@ -1,4 +1,6 @@
'use client'; 'use client';
// RULES:
// refer to ticket REQ0016 for login flow
import * as React from 'react'; import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
@@ -25,6 +27,7 @@ import { authClient } from '@/lib/auth/custom/client';
import { useUser } from '@/hooks/use-user'; import { useUser } from '@/hooks/use-user';
import { DynamicLogo } from '@/components/core/logo'; import { DynamicLogo } from '@/components/core/logo';
import { toast } from '@/components/core/toaster'; import { toast } from '@/components/core/toaster';
import { pb } from '@/lib/pb';
interface OAuthProvider { interface OAuthProvider {
id: 'google' | 'discord'; id: 'google' | 'discord';
@@ -44,7 +47,7 @@ const schema = zod.object({
type Values = zod.infer<typeof schema>; type Values = zod.infer<typeof schema>;
const defaultValues = { email: '', password: '' } satisfies Values; const defaultValues = { email: 'admin@123.com', password: 'admin@123.com' } satisfies Values;
export function SignInForm(): React.JSX.Element { export function SignInForm(): React.JSX.Element {
const router = useRouter(); const router = useRouter();
@@ -103,15 +106,31 @@ export function SignInForm(): React.JSX.Element {
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<div> <div>
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}> <Box
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} /> component={RouterLink}
href={paths.home}
sx={{ display: 'inline-block', fontSize: 0 }}
>
<DynamicLogo
colorDark="light"
colorLight="dark"
height={32}
width={122}
/>
</Box> </Box>
</div> </div>
<Stack spacing={1}> <Stack spacing={1}>
<Typography variant="h5">Sign in</Typography> <Typography variant="h5">Sign in</Typography>
<Typography color="text.secondary" variant="body2"> <Typography
color="text.secondary"
variant="body2"
>
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<Link component={RouterLink} href={paths.auth.custom.signUp} variant="subtitle2"> <Link
component={RouterLink}
href={paths.auth.custom.signUp}
variant="subtitle2"
>
Sign up Sign up
</Link> </Link>
</Typography> </Typography>
@@ -123,7 +142,15 @@ export function SignInForm(): React.JSX.Element {
<Button <Button
color="secondary" color="secondary"
disabled={isPending} disabled={isPending}
endIcon={<Box alt="" component="img" height={24} src={provider.logo} width={24} />} endIcon={
<Box
alt=""
component="img"
height={24}
src={provider.logo}
width={24}
/>
}
key={provider.id} key={provider.id}
onClick={(): void => { onClick={(): void => {
onAuth(provider.id).catch(() => { onAuth(provider.id).catch(() => {
@@ -147,7 +174,10 @@ export function SignInForm(): React.JSX.Element {
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.email)}> <FormControl error={Boolean(errors.email)}>
<InputLabel>Email address</InputLabel> <InputLabel>Email address</InputLabel>
<OutlinedInput {...field} type="email" /> <OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null} {errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
@@ -187,27 +217,65 @@ export function SignInForm(): React.JSX.Element {
)} )}
/> />
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null} {errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
<Button disabled={isPending} type="submit" variant="contained"> <Button
disabled={isPending}
type="submit"
variant="contained"
>
Sign in Sign in
</Button> </Button>
</Stack> </Stack>
</form> </form>
<div> <div>
<Link component={RouterLink} href={paths.auth.custom.resetPassword} variant="subtitle2"> <Link
component={RouterLink}
href={paths.auth.custom.resetPassword}
variant="subtitle2"
>
Forgot password? Forgot password?
</Link> </Link>
</div> </div>
</Stack> </Stack>
</Stack> </Stack>
<Alert color="warning"> <Alert color="warning">
Use{' '} <Stack>
<Typography component="span" sx={{ fontWeight: 700 }} variant="inherit"> <Box>
sofia@devias.io user:{' '}
</Typography>{' '} <Typography
with password{' '} component="span"
<Typography component="span" sx={{ fontWeight: 700 }} variant="inherit"> sx={{ fontWeight: 700 }}
Secret1 variant="inherit"
</Typography> >
admin@123.com
</Typography>{' '}
password{' '}
<Typography
component="span"
sx={{ fontWeight: 700 }}
variant="inherit"
>
admin@123.com
</Typography>
</Box>
<Box>
user{' '}
<Typography
component="span"
sx={{ fontWeight: 700 }}
variant="inherit"
>
sofia@devias.io
</Typography>{' '}
password{' '}
<Typography
component="span"
sx={{ fontWeight: 700 }}
variant="inherit"
>
Secret1
</Typography>
</Box>
</Stack>
</Alert> </Alert>
</Stack> </Stack>
); );

View File

@@ -115,15 +115,31 @@ export function SignInForm(): React.JSX.Element {
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<div> <div>
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}> <Box
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} /> component={RouterLink}
href={paths.home}
sx={{ display: 'inline-block', fontSize: 0 }}
>
<DynamicLogo
colorDark="light"
colorLight="dark"
height={32}
width={122}
/>
</Box> </Box>
</div> </div>
<Stack spacing={1}> <Stack spacing={1}>
<Typography variant="h5">Sign in</Typography> <Typography variant="h5">Sign in</Typography>
<Typography color="text.secondary" variant="body2"> <Typography
color="text.secondary"
variant="body2"
>
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<Link component={RouterLink} href={paths.auth.supabase.signUp} variant="subtitle2"> <Link
component={RouterLink}
href={paths.auth.supabase.signUp}
variant="subtitle2"
>
Sign up Sign up
</Link> </Link>
</Typography> </Typography>
@@ -135,7 +151,15 @@ export function SignInForm(): React.JSX.Element {
<Button <Button
color="secondary" color="secondary"
disabled={isPending} disabled={isPending}
endIcon={<Box alt="" component="img" height={24} src={provider.logo} width={24} />} endIcon={
<Box
alt=""
component="img"
height={24}
src={provider.logo}
width={24}
/>
}
key={provider.id} key={provider.id}
onClick={(): void => { onClick={(): void => {
onAuth(provider.id).catch(() => { onAuth(provider.id).catch(() => {
@@ -159,7 +183,10 @@ export function SignInForm(): React.JSX.Element {
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.email)}> <FormControl error={Boolean(errors.email)}>
<InputLabel>Email address</InputLabel> <InputLabel>Email address</InputLabel>
<OutlinedInput {...field} type="email" /> <OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null} {errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
@@ -199,13 +226,21 @@ export function SignInForm(): React.JSX.Element {
)} )}
/> />
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null} {errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
<Button disabled={isPending} type="submit" variant="contained"> <Button
disabled={isPending}
type="submit"
variant="contained"
>
Sign in Sign in
</Button> </Button>
</Stack> </Stack>
</form> </form>
<div> <div>
<Link component={RouterLink} href={paths.auth.supabase.resetPassword} variant="subtitle2"> <Link
component={RouterLink}
href={paths.auth.supabase.resetPassword}
variant="subtitle2"
>
Forgot password? Forgot password?
</Link> </Link>
</div> </div>

View File

@@ -112,9 +112,15 @@ export function ContactsPopover({ anchorEl, onClose, open = false }: ContactsPop
<Typography variant="h6">Contacts</Typography> <Typography variant="h6">Contacts</Typography>
</Box> </Box>
<Box sx={{ maxHeight: '400px', overflowY: 'auto', px: 1, pb: 2 }}> <Box sx={{ maxHeight: '400px', overflowY: 'auto', px: 1, pb: 2 }}>
<List disablePadding sx={{ '& .MuiListItemButton-root': { borderRadius: 1 } }}> <List
disablePadding
sx={{ '& .MuiListItemButton-root': { borderRadius: 1 } }}
>
{contacts.map((contact) => ( {contacts.map((contact) => (
<ListItem disablePadding key={contact.id}> <ListItem
disablePadding
key={contact.id}
>
<ListItemButton> <ListItemButton>
<ListItemAvatar> <ListItemAvatar>
<Avatar src={contact.avatar} /> <Avatar src={contact.avatar} />
@@ -122,14 +128,28 @@ export function ContactsPopover({ anchorEl, onClose, open = false }: ContactsPop
<ListItemText <ListItemText
disableTypography disableTypography
primary={ primary={
<Link color="text.primary" noWrap underline="none" variant="subtitle2"> <Link
color="text.primary"
noWrap
underline="none"
variant="subtitle2"
>
{contact.name} {contact.name}
</Link> </Link>
} }
/> />
{contact.status !== 'offline' ? <Presence size="small" status={contact.status} /> : null} {contact.status !== 'offline' ? (
<Presence
size="small"
status={contact.status}
/>
) : null}
{contact.status === 'offline' && Boolean(contact.lastActivity) ? ( {contact.status === 'offline' && Boolean(contact.lastActivity) ? (
<Typography color="text.secondary" sx={{ whiteSpace: 'nowrap' }} variant="caption"> <Typography
color="text.secondary"
sx={{ whiteSpace: 'nowrap' }}
variant="caption"
>
{dayjs(contact.lastActivity).fromNow()} {dayjs(contact.lastActivity).fromNow()}
</Typography> </Typography>
) : null} ) : null}

View File

@@ -34,6 +34,7 @@ export function HorizontalLayout({ children }: HorizontalLayoutProps): React.JSX
color={settings.navColor} color={settings.navColor}
items={layoutConfig.navItems} items={layoutConfig.navItems}
/> />
<Box <Box
component="main" component="main"
sx={{ sx={{

View File

@@ -36,15 +36,15 @@ import { Logo } from '@/components/core/logo';
import { SearchDialog } from '@/components/dashboard/layout/search-dialog'; import { SearchDialog } from '@/components/dashboard/layout/search-dialog';
import type { ColorScheme } from '@/styles/theme/types'; import type { ColorScheme } from '@/styles/theme/types';
import { ContactsPopover } from '../contacts-popover'; import { ContactsPopover } from '../../contacts-popover';
import { languageFlags, LanguagePopover } from '../language-popover'; import { languageFlags, LanguagePopover } from '../../language-popover';
import type { Language } from '../language-popover'; import type { Language } from '../../language-popover';
import { MobileNav } from '../mobile-nav'; import { MobileNav } from '../../mobile-nav';
import { icons } from '../nav-icons'; import { icons } from '../../nav-icons';
import { NotificationsPopover } from '../notifications-popover'; import { NotificationsPopover } from '../../notifications-popover';
import { UserPopover } from '../user-popover/user-popover'; import { UserPopover } from '../../user-popover/user-popover';
import { WorkspacesSwitch } from '../workspaces-switch'; import { WorkspacesSwitch } from '../../workspaces-switch';
import { navColorStyles } from './styles'; import { navColorStyles } from '../styles';
const logoColors = { const logoColors = {
dark: { blend_in: 'light', discrete: 'light', evident: 'light' }, dark: { blend_in: 'light', discrete: 'light', evident: 'light' },
@@ -92,7 +92,11 @@ export function MainNav({ color = 'evident', items = [] }: MainNavProps): React.
py: 1, py: 1,
}} }}
> >
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto' }}> <Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto' }}
>
<IconButton <IconButton
onClick={(): void => { onClick={(): void => {
setOpenNav(true); setOpenNav(true);
@@ -101,8 +105,16 @@ export function MainNav({ color = 'evident', items = [] }: MainNavProps): React.
> >
<ListIcon color="var(--NavItem-icon-color)" /> <ListIcon color="var(--NavItem-icon-color)" />
</IconButton> </IconButton>
<Box component={RouterLink} href={paths.home} sx={{ display: { xs: 'none', md: 'inline-block' } }}> <Box
<Logo color={logoColor} height={32} width={122} /> component={RouterLink}
href={paths.home}
sx={{ display: { xs: 'none', md: 'inline-block' } }}
>
<Logo
color={logoColor}
height={32}
width={122}
/>
</Box> </Box>
<Box sx={{ display: { xs: 'none', md: 'block' } }}> <Box sx={{ display: { xs: 'none', md: 'block' } }}>
<WorkspacesSwitch /> <WorkspacesSwitch />
@@ -154,11 +166,17 @@ function SearchButton(): React.JSX.Element {
return ( return (
<React.Fragment> <React.Fragment>
<Tooltip title="Search"> <Tooltip title="Search">
<IconButton onClick={dialog.handleOpen} sx={{ display: { xs: 'none', md: 'inline-flex' } }}> <IconButton
onClick={dialog.handleOpen}
sx={{ display: { xs: 'none', md: 'inline-flex' } }}
>
<MagnifyingGlassIcon color="var(--NavItem-icon-color)" /> <MagnifyingGlassIcon color="var(--NavItem-icon-color)" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<SearchDialog onClose={dialog.handleClose} open={dialog.open} /> <SearchDialog
onClose={dialog.handleClose}
open={dialog.open}
/>
</React.Fragment> </React.Fragment>
); );
} }
@@ -171,15 +189,30 @@ function NotificationsButton(): React.JSX.Element {
<Tooltip title="Notifications"> <Tooltip title="Notifications">
<Badge <Badge
color="error" color="error"
sx={{ '& .MuiBadge-dot': { borderRadius: '50%', height: '10px', right: '6px', top: '6px', width: '10px' } }} sx={{
'& .MuiBadge-dot': {
borderRadius: '50%',
right: '6px',
top: '6px',
height: '10px',
width: '10px',
},
}}
variant="dot" variant="dot"
> >
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}> <IconButton
onClick={popover.handleOpen}
ref={popover.anchorRef}
>
<BellIcon color="var(--NavItem-icon-color)" /> <BellIcon color="var(--NavItem-icon-color)" />
</IconButton> </IconButton>
</Badge> </Badge>
</Tooltip> </Tooltip>
<NotificationsPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} /> <NotificationsPopover
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
open={popover.open}
/>
</React.Fragment> </React.Fragment>
); );
} }
@@ -190,11 +223,18 @@ function ContactsButton(): React.JSX.Element {
return ( return (
<React.Fragment> <React.Fragment>
<Tooltip title="Contacts"> <Tooltip title="Contacts">
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}> <IconButton
onClick={popover.handleOpen}
ref={popover.anchorRef}
>
<UsersIcon color="var(--NavItem-icon-color)" /> <UsersIcon color="var(--NavItem-icon-color)" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<ContactsPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} /> <ContactsPopover
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
open={popover.open}
/>
</React.Fragment> </React.Fragment>
); );
} }
@@ -214,11 +254,20 @@ function LanguageSwitch(): React.JSX.Element {
sx={{ display: { xs: 'none', md: 'inline-flex' } }} sx={{ display: { xs: 'none', md: 'inline-flex' } }}
> >
<Box sx={{ height: '24px', width: '24px' }}> <Box sx={{ height: '24px', width: '24px' }}>
<Box alt={language} component="img" src={flag} sx={{ height: 'auto', width: '100%' }} /> <Box
alt={language}
component="img"
src={flag}
sx={{ height: 'auto', width: '100%' }}
/>
</Box> </Box>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<LanguagePopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} /> <LanguagePopover
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
open={popover.open}
/>
</React.Fragment> </React.Fragment>
); );
} }
@@ -259,7 +308,11 @@ function UserButton(): React.JSX.Element {
<Avatar src={user.avatar} /> <Avatar src={user.avatar} />
</Badge> </Badge>
</Box> </Box>
<UserPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} /> <UserPopover
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
open={popover.open}
/>
</React.Fragment> </React.Fragment>
); );
} }
@@ -267,7 +320,11 @@ function UserButton(): React.JSX.Element {
function renderNavGroups({ items = [], pathname }: { items?: NavItemConfig[]; pathname: string }): React.JSX.Element { function renderNavGroups({ items = [], pathname }: { items?: NavItemConfig[]; pathname: string }): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => { const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
acc.push( acc.push(
<Box component="li" key={curr.key} sx={{ flex: '0 0 auto' }}> <Box
component="li"
key={curr.key}
sx={{ flex: '0 0 auto' }}
>
{renderNavItems({ pathname, items: curr.items })} {renderNavItems({ pathname, items: curr.items })}
</Box> </Box>
); );
@@ -276,7 +333,12 @@ function renderNavGroups({ items = [], pathname }: { items?: NavItemConfig[]; pa
}, []); }, []);
return ( return (
<Stack component="ul" direction="row" spacing={2} sx={{ listStyle: 'none', m: 0, p: '8px 12px' }}> <Stack
component="ul"
direction="row"
spacing={2}
sx={{ listStyle: 'none', m: 0, p: '8px 12px' }}
>
{children} {children}
</Stack> </Stack>
); );
@@ -286,13 +348,24 @@ function renderNavItems({ items = [], pathname }: { items?: NavItemConfig[]; pat
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => { const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { key, ...item } = curr; const { key, ...item } = curr;
acc.push(<NavItem key={key} pathname={pathname} {...item} />); acc.push(
<NavItem
key={key}
pathname={pathname}
{...item}
/>
);
return acc; return acc;
}, []); }, []);
return ( return (
<Stack component="ul" direction="row" spacing={2} sx={{ listStyle: 'none', m: 0, p: 0 }}> <Stack
component="ul"
direction="row"
spacing={2}
sx={{ listStyle: 'none', m: 0, p: 0 }}
>
{children} {children}
</Stack> </Stack>
); );
@@ -318,7 +391,10 @@ function NavItem({
const isBranch = Boolean(items); const isBranch = Boolean(items);
const element = ( const element = (
<Box component="li" sx={{ userSelect: 'none' }}> <Box
component="li"
sx={{ userSelect: 'none' }}
>
<Box <Box
{...(isBranch {...(isBranch
? { role: 'button' } ? { role: 'button' }
@@ -373,10 +449,19 @@ function NavItem({
{title} {title}
</Typography> </Typography>
</Box> </Box>
{label ? <Chip color="primary" label={label} size="small" /> : null} {label ? (
<Chip
color="primary"
label={label}
size="small"
/>
) : null}
{external ? ( {external ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}> <Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ArrowSquareOutIcon color="var(--NavItem-icon-color)" fontSize="var(--icon-fontSize-sm)" /> <ArrowSquareOutIcon
color="var(--NavItem-icon-color)"
fontSize="var(--icon-fontSize-sm)"
/>
</Box> </Box>
) : null} ) : null}
{isBranch ? ( {isBranch ? (
@@ -415,13 +500,23 @@ function renderDropdownItems({
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => { const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { key, ...item } = curr; const { key, ...item } = curr;
acc.push(<DropdownItem key={key} pathname={pathname} {...item} />); acc.push(
<DropdownItem
key={key}
pathname={pathname}
{...item}
/>
);
return acc; return acc;
}, []); }, []);
return ( return (
<Stack component="ul" spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}> <Stack
component="ul"
spacing={1}
sx={{ listStyle: 'none', m: 0, p: 0 }}
>
{children} {children}
</Stack> </Stack>
); );
@@ -444,7 +539,10 @@ function DropdownItem({
const isBranch = Boolean(items); const isBranch = Boolean(items);
const element = ( const element = (
<Box component="li" sx={{ userSelect: 'none' }}> <Box
component="li"
sx={{ userSelect: 'none' }}
>
<Box <Box
{...(isBranch {...(isBranch
? { role: 'button' } ? { role: 'button' }

View File

@@ -1,38 +1,42 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import Avatar from '@mui/material/Avatar'; import { getNotificationsByUserId } from '@/db/Notifications/GetNotificationByUserId';
import type { Notification } from '@/db/Notifications/type';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import Popover from '@mui/material/Popover'; import Popover from '@mui/material/Popover';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { ChatText as ChatTextIcon } from '@phosphor-icons/react/dist/ssr/ChatText';
import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple'; import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { dayjs } from '@/lib/dayjs'; import { User } from '@/types/user';
import { useHelloworld } from '@/hooks/use-helloworld';
import { useUser } from '@/hooks/use-user';
import { NotificationItem } from './notification-item';
// import type { Notification } from './type.d.tsx.del'; // import type { Notification } from './type.d.tsx.del';
import { SampleNotifications } from './sample-notifications'; import { SampleNotifications } from './sample-notifications';
import { useHelloworld } from '@/hooks/use-helloworld'; import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { getAllNotifications } from '@/db/Notifications/GetAll'; import { getUnreadNotificationsByUserId } from '@/db/Notifications/GetUnreadNotificationsByUserId';
import { ListResult, RecordModel } from 'pocketbase'; import { logger } from '@/lib/default-logger';
import { defaultNotification } from '@/db/Notifications/constants'; import { toast } from '@/components/core/toaster';
import { getNotificationsByUserId } from '@/db/Notifications/GetNotificationByUserId';
import { Notification } from '@/db/Notifications/type'; import { NoteBlank as NoteBlankIcon } from '@phosphor-icons/react/dist/ssr/NoteBlank';
import { Sun as SunIcon } from '@phosphor-icons/react/dist/ssr/Sun';
import { MarkAllAsReadButton } from './mark-all-as-read-button';
import { Button } from '@mui/material';
export interface NotificationsPopoverProps { export interface NotificationsPopoverProps {
anchorEl: null | Element; anchorEl: null | Element;
onClose?: () => void; onClose?: () => void;
onMarkAllAsRead?: () => void; onMarkAllAsRead?: () => void;
onRemoveOne?: (id: string) => void; onRemoveOne?: (id: string, reload: () => Promise<void>) => void;
open?: boolean; open?: boolean;
setListLength: React.Dispatch<React.SetStateAction<number>>;
} }
export function NotificationsPopover({ export function NotificationsPopover({
@@ -41,37 +45,101 @@ export function NotificationsPopover({
onMarkAllAsRead, onMarkAllAsRead,
onRemoveOne, onRemoveOne,
open = false, open = false,
setListLength,
}: NotificationsPopoverProps): React.JSX.Element { }: NotificationsPopoverProps): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const [notiList, setNotiList] = React.useState<Notification[]>([]); const [notiList, setNotiList] = React.useState<Notification[]>([]);
const { user } = useUser();
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [showError, setShowError] = React.useState<boolean>(false);
const { data, handleClose, handleOpen, open: testOpen } = useHelloworld<string>(); async function loadUnreadNotifications(): Promise<void> {
React.useEffect(() => {
setLoading(true); setLoading(true);
async function LoadAllNotifications() { try {
const notiList: Notification[] = await getNotificationsByUserId('1'); if (user?.id) {
setNotiList(notiList); const tempNotiList: Notification[] = await getUnreadNotificationsByUserId(user.id);
setNotiList(tempNotiList);
setListLength(tempNotiList.length);
}
} catch (loadNotiError) {
logger.error(loadNotiError);
toast.error('error during loading noti list');
} }
setLoading(false);
void LoadAllNotifications();
}, []);
if (loading) return <>Loading</>; setLoading(false);
if (error) return <>Error</>; }
if (notiList.length == 0)
React.useEffect(() => {
if (user?.id) {
void loadUnreadNotifications();
}
}, [user]);
// if (loading) return <>Loading</>;
// if (showError) return <>Error</>;
if (notiList.length === 0)
return ( return (
<Popover <Popover
anchorEl={anchorEl} anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose} onClose={onClose}
open={open} open={open}
slotProps={{ paper: { sx: { width: '380px' } } }} slotProps={{ paper: { sx: { width: 'unset' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }} transformOrigin={{ horizontal: 'right', vertical: 'top' }}
> >
list is empty <Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, py: 2 }}
>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'left' }}
>
<Typography variant="h6">
{t('Notifications')} ({notiList.length})
</Typography>
{loading ? (
<Typography
color="gray"
variant="subtitle2"
>
({t('loading')})
</Typography>
) : (
<></>
)}
</Stack>
{/* MarkAllAsReadButton(onMarkAllAsRead, notiList.length <= 0) */}
<MarkAllAsReadButton
onMarkAllAsRead={onMarkAllAsRead}
disabled={notiList.length <= 0}
/>
</Stack>
<Stack
direction="column"
spacing={2}
sx={{ alignItems: 'center', padding: '50px' }}
>
<SunIcon
size={48}
color="lightgray"
/>
<Typography
color="lightgray"
variant={'subtitle2'}
>
{t('list-is-empty')}
</Typography>
<Button variant="outlined">{t('refresh')}</Button>
</Stack>
</Popover> </Popover>
); );
@@ -80,6 +148,7 @@ export function NotificationsPopover({
anchorEl={anchorEl} anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose} onClose={onClose}
// todo: should not use 'true', fallback to 'open'
open={open} open={open}
slotProps={{ paper: { sx: { width: '380px' } } }} slotProps={{ paper: { sx: { width: '380px' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }} transformOrigin={{ horizontal: 'right', vertical: 'top' }}
@@ -89,16 +158,32 @@ export function NotificationsPopover({
spacing={2} spacing={2}
sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, py: 2 }} sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, py: 2 }}
> >
<Typography variant="h6">{t('Notifications')}</Typography> <Stack
<Tooltip title={t('Mark all as read')}> direction="row"
<IconButton spacing={2}
edge="end" sx={{ alignItems: 'left' }}
onClick={onMarkAllAsRead} >
> <Typography variant="h6">
<EnvelopeSimpleIcon /> {t('Notifications')} ({notiList.length})
</IconButton> </Typography>
</Tooltip>
{loading ? (
<Typography
color="gray"
variant="subtitle2"
>
({t('loading')})
</Typography>
) : (
<></>
)}
</Stack>
<MarkAllAsReadButton
onMarkAllAsRead={onMarkAllAsRead}
disabled={notiList.length <= 0}
/>
</Stack> </Stack>
{notiList.length === 0 ? ( {notiList.length === 0 ? (
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
<Typography variant="subtitle2">{t('There are no notifications')}</Typography> <Typography variant="subtitle2">{t('There are no notifications')}</Typography>
@@ -108,11 +193,11 @@ export function NotificationsPopover({
<List disablePadding> <List disablePadding>
{notiList.map((notification, index) => ( {notiList.map((notification, index) => (
<NotificationItem <NotificationItem
divider={index < SampleNotifications.length - 1} divider={index < notiList.length - 1}
key={notification.id} key={notification.id}
notification={notification} notification={notification}
onRemove={() => { onRemove={() => {
onRemoveOne?.(notification.id); onRemoveOne?.(notification.id, () => loadUnreadNotifications());
}} }}
/> />
))} ))}
@@ -123,135 +208,25 @@ export function NotificationsPopover({
); );
} }
interface NotificationItemProps { // TODO: remove me
divider?: boolean; // function MarkAllAsReadButton({
notification: Notification; // onMarkAllAsRead,
onRemove?: () => void; // disabled,
} // }: {
// onMarkAllAsRead: (() => void) | undefined;
// disabled: boolean;
// }): React.JSX.Element {
// const { t } = useTranslation();
function NotificationItem({ divider, notification, onRemove }: NotificationItemProps): React.JSX.Element { // return (
return ( // <Tooltip title={t('mark-all-as-read')}>
<ListItem // <IconButton
divider={divider} // edge="end"
sx={{ alignItems: 'flex-start', justifyContent: 'space-between' }} // onClick={onMarkAllAsRead}
> // disabled={disabled}
<NotificationContent notification={notification} /> // >
<Tooltip title="Remove"> // <EnvelopeSimpleIcon color={disabled ? 'lightgray' : 'black'} />
<IconButton // </IconButton>
edge="end" // </Tooltip>
onClick={onRemove} // );
size="small" // }
>
<XIcon />
</IconButton>
</Tooltip>
</ListItem>
);
}
interface NotificationContentProps {
notification: Notification;
}
function NotificationContent({ notification }: NotificationContentProps): React.JSX.Element {
if (notification.type === 'new_feature') {
return (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'flex-start' }}
>
<Avatar>
<ChatTextIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
<div>
<Typography variant="subtitle2">New feature!</Typography>
<Typography variant="body2">{notification.description}</Typography>
<Typography
color="text.secondary"
variant="caption"
>
{dayjs(notification.createdAt).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
if (notification.type === 'new_company') {
return (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'flex-start' }}
>
<Avatar src={notification.author.avatar}>
<UserIcon />
</Avatar>
<div>
<Typography variant="body2">
<Typography
component="span"
variant="subtitle2"
>
{notification.author.name}
</Typography>{' '}
created{' '}
<Link
underline="always"
variant="body2"
>
{notification.company.name}
</Link>{' '}
company
</Typography>
<Typography
color="text.secondary"
variant="caption"
>
{dayjs(notification.createdAt).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
if (notification.type === 'new_job') {
return (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'flex-start' }}
>
<Avatar src={notification.author.avatar}>
<UserIcon />
</Avatar>
<div>
<Typography variant="body2">
<Typography
component="span"
variant="subtitle2"
>
{notification.author.name}
</Typography>{' '}
added a new job{' '}
<Link
underline="always"
variant="body2"
>
{notification.job.title}
</Link>
</Typography>
<Typography
color="text.secondary"
variant="caption"
>
{dayjs(notification.createdAt).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
return <div />;
}

View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
import { useTranslation } from 'react-i18next';
export function MarkAllAsReadButton({
onMarkAllAsRead,
disabled,
}: {
onMarkAllAsRead: (() => void) | undefined;
disabled: boolean;
}): React.JSX.Element {
const { t } = useTranslation();
return (
<Tooltip title={t('mark-all-as-read')}>
<IconButton
edge="end"
onClick={onMarkAllAsRead}
disabled={disabled}
>
<EnvelopeSimpleIcon color={disabled ? 'lightgray' : 'black'} />
</IconButton>
</Tooltip>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import type { Notification } from '@/db/Notifications/type';
import Avatar from '@mui/material/Avatar';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ChatText as ChatTextIcon } from '@phosphor-icons/react/dist/ssr/ChatText';
import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
import { dayjs } from '@/lib/dayjs';
import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { useRouter } from 'next/navigation';
import { toast } from '@/components/core/toaster';
interface NotificationContentProps {
notification: Notification;
}
export function NotificationContent({ notification }: NotificationContentProps): React.JSX.Element {
const router = useRouter();
if (notification.type === 'new_feature') {
return (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'flex-start' }}
>
<Avatar>
<ChatTextIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
<div>
<Typography variant="subtitle2">New feature!</Typography>
<Typography variant="body2">{notification.description}</Typography>
<Typography
color="text.secondary"
variant="caption"
>
{dayjs(notification.createdAt).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
if (notification.type === 'new_company') {
return (
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'flex-start' }}
>
<Avatar src={notification?.author?.avatar || ''}>
<UserIcon size={24} />
</Avatar>
<div>
<Typography variant="body2">
<Typography
component="span"
variant="subtitle2"
>
{notification?.author?.name}
</Typography>{' '}
created{' '}
<Link
underline="always"
variant="body2"
>
{notification?.company?.name}
</Link>{' '}
company
</Typography>
<Typography
color="text.secondary"
variant="caption"
>
{dayjs(notification.created).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
if (notification.type === 'new_job') {
const handleClick = (): void => {
try {
if (notification.link) {
router.push(notification.link);
}
} catch (error) {
toast.error((error as { message: string }).message);
}
};
return (
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'flex-start',
cursor: notification.link ? 'pointer' : '',
}}
//
onClick={handleClick}
>
<Avatar src={notification?.author?.avatar}>
<UserIcon />
</Avatar>
<div>
<Typography variant="body2">
<Typography
component="span"
variant="subtitle2"
>
{notification?.author?.name}
</Typography>{' '}
added a new job{' '}
<Link
underline="always"
variant="body2"
>
{notification?.job?.title}
</Link>
</Typography>
<Typography
color="text.secondary"
variant="caption"
>
{dayjs(notification.created).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
return <div />;
}

View File

@@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
import type { Notification } from '@/db/Notifications/type';
import IconButton from '@mui/material/IconButton';
import ListItem from '@mui/material/ListItem';
import Tooltip from '@mui/material/Tooltip';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { NotificationContent } from './notification-content';
interface NotificationItemProps {
divider?: boolean;
notification: Notification;
onRemove?: () => void;
}
export function NotificationItem({ divider, notification, onRemove }: NotificationItemProps): React.JSX.Element {
return (
<ListItem
divider={divider}
sx={{ alignItems: 'flex-start', justifyContent: 'space-between' }}
>
<NotificationContent notification={notification} />
<Tooltip title="Remove">
<IconButton
edge="end"
onClick={onRemove}
size="small"
>
<XIcon />
</IconButton>
</Tooltip>
</ListItem>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Notification } from '@/db/Notifications/type';
import { dayjs } from '@/lib/dayjs'; import { dayjs } from '@/lib/dayjs';
import type { Notification } from './type.d.tsx.del'; // import type { Notification } from './type.d.tsx';
export const SampleNotifications = [ export const SampleNotifications = [
{ {
@@ -8,7 +9,7 @@ export const SampleNotifications = [
createdAt: dayjs().subtract(7, 'minute').subtract(5, 'hour').subtract(1, 'day').toDate(), createdAt: dayjs().subtract(7, 'minute').subtract(5, 'hour').subtract(1, 'day').toDate(),
read: false, read: false,
type: 'new_job', type: 'new_job',
author: { name: 'Jie Yan', avatar: '/assets/avatar-8.png' }, author: { id: '0001', collectionId: '0001', name: 'Jie Yan', avatar: '/assets/avatar-8.png' },
job: { title: 'Remote React / React Native Developer' }, job: { title: 'Remote React / React Native Developer' },
}, },
{ {
@@ -16,7 +17,7 @@ export const SampleNotifications = [
createdAt: dayjs().subtract(18, 'minute').subtract(3, 'hour').subtract(5, 'day').toDate(), createdAt: dayjs().subtract(18, 'minute').subtract(3, 'hour').subtract(5, 'day').toDate(),
read: true, read: true,
type: 'new_job', type: 'new_job',
author: { name: 'Fran Perez', avatar: '/assets/avatar-5.png' }, author: { id: '0001', collectionId: '0001', name: 'Fran Perez', avatar: '/assets/avatar-5.png' },
job: { title: 'Senior Golang Backend Engineer' }, job: { title: 'Senior Golang Backend Engineer' },
}, },
{ {
@@ -24,6 +25,7 @@ export const SampleNotifications = [
createdAt: dayjs().subtract(4, 'minute').subtract(5, 'hour').subtract(7, 'day').toDate(), createdAt: dayjs().subtract(4, 'minute').subtract(5, 'hour').subtract(7, 'day').toDate(),
read: true, read: true,
type: 'new_feature', type: 'new_feature',
author: { id: '0001', collectionId: '0001', name: 'Fran Perez', avatar: '/assets/avatar-5.png' },
description: 'Logistics management is now available', description: 'Logistics management is now available',
}, },
{ {
@@ -31,7 +33,7 @@ export const SampleNotifications = [
createdAt: dayjs().subtract(7, 'minute').subtract(8, 'hour').subtract(7, 'day').toDate(), createdAt: dayjs().subtract(7, 'minute').subtract(8, 'hour').subtract(7, 'day').toDate(),
read: true, read: true,
type: 'new_company', type: 'new_company',
author: { name: 'Jie Yan', avatar: '/assets/avatar-8.png' }, author: { id: '0001', collectionId: '002', name: 'Jie Yan', avatar: '/assets/avatar-8.png' },
company: { name: 'Stripe' }, company: { name: 'Stripe' },
}, },
] satisfies Notification[]; ] satisfies Notification[];

View File

@@ -37,7 +37,11 @@ export function CustomSignOut(): React.JSX.Element {
}, [checkSession, router]); }, [checkSession, router]);
return ( return (
<MenuItem component="div" onClick={handleSignOut} sx={{ justifyContent: 'center' }}> <MenuItem
component="div"
onClick={handleSignOut}
sx={{ justifyContent: 'center' }}
>
Sign out Sign out
</MenuItem> </MenuItem>
); );

View File

@@ -23,8 +23,10 @@ import { CognitoSignOut } from './cognito-sign-out';
import { CustomSignOut } from './custom-sign-out'; import { CustomSignOut } from './custom-sign-out';
import { FirebaseSignOut } from './firebase-sign-out'; import { FirebaseSignOut } from './firebase-sign-out';
import { SupabaseSignOut } from './supabase-sign-out'; import { SupabaseSignOut } from './supabase-sign-out';
import { authClient } from '@/lib/auth/custom/client';
import { logger } from '@/lib/default-logger';
const user = { const defaultUser = {
id: 'USR-000', id: 'USR-000',
name: 'Sofia Rivers', name: 'Sofia Rivers',
avatar: '/assets/avatar.png', avatar: '/assets/avatar.png',
@@ -38,6 +40,23 @@ export interface UserPopoverProps {
} }
export function UserPopover({ anchorEl, onClose, open }: UserPopoverProps): React.JSX.Element { export function UserPopover({ anchorEl, onClose, open }: UserPopoverProps): React.JSX.Element {
const [userMeta, setUserMeta] = React.useState<User>(defaultUser);
async function loadUserMeta(): Promise<void> {
try {
const tempUserMeta = await authClient.getUser();
if (tempUserMeta.error) throw new Error(tempUserMeta.error);
setUserMeta(tempUserMeta.data as unknown as User);
} catch (error) {
logger.error(error);
}
}
React.useEffect(() => {
void loadUserMeta();
}, []);
if (!userMeta) return <>loading</>;
return ( return (
<Popover <Popover
anchorEl={anchorEl} anchorEl={anchorEl}
@@ -48,26 +67,41 @@ export function UserPopover({ anchorEl, onClose, open }: UserPopoverProps): Reac
transformOrigin={{ horizontal: 'right', vertical: 'top' }} transformOrigin={{ horizontal: 'right', vertical: 'top' }}
> >
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
<Typography>{user.name}</Typography> <Typography>{userMeta.name}</Typography>
<Typography color="text.secondary" variant="body2"> <Typography
{user.email} color="text.secondary"
variant="body2"
>
{userMeta.email}
</Typography> </Typography>
</Box> </Box>
<Divider /> <Divider />
<List sx={{ p: 1 }}> <List sx={{ p: 1 }}>
<MenuItem component={RouterLink} href={paths.dashboard.settings.account} onClick={onClose}> <MenuItem
component={RouterLink}
href={paths.dashboard.settings.account}
onClick={onClose}
>
<ListItemIcon> <ListItemIcon>
<UserIcon /> <UserIcon />
</ListItemIcon> </ListItemIcon>
Account Account
</MenuItem> </MenuItem>
<MenuItem component={RouterLink} href={paths.dashboard.settings.security} onClick={onClose}> <MenuItem
component={RouterLink}
href={paths.dashboard.settings.security}
onClick={onClose}
>
<ListItemIcon> <ListItemIcon>
<LockKeyIcon /> <LockKeyIcon />
</ListItemIcon> </ListItemIcon>
Security Security
</MenuItem> </MenuItem>
<MenuItem component={RouterLink} href={paths.dashboard.settings.billing} onClick={onClose}> <MenuItem
component={RouterLink}
href={paths.dashboard.settings.billing}
onClick={onClose}
>
<ListItemIcon> <ListItemIcon>
<CreditCardIcon /> <CreditCardIcon />
</ListItemIcon> </ListItemIcon>

View File

@@ -1,216 +0,0 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { Bell as BellIcon } from '@phosphor-icons/react/dist/ssr/Bell';
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { Users as UsersIcon } from '@phosphor-icons/react/dist/ssr/Users';
import { useTranslation } from 'next-i18next';
import type { NavItemConfig } from '@/types/nav';
import type { User } from '@/types/user';
import { useDialog } from '@/hooks/use-dialog';
import { usePopover } from '@/hooks/use-popover';
import { ContactsPopover } from '../contacts-popover';
import { languageFlags, LanguagePopover } from '../language-popover';
import type { Language } from '../language-popover';
import { MobileNav } from '../mobile-nav';
import { NotificationsPopover } from '../notifications-popover';
import { SearchDialog } from '../search-dialog';
import { UserPopover } from '../user-popover/user-popover';
export interface MainNavProps {
items: NavItemConfig[];
}
export function MainNav({ items }: MainNavProps): React.JSX.Element {
const [openNav, setOpenNav] = React.useState<boolean>(false);
return (
<React.Fragment>
<Box
component="header"
sx={{
'--MainNav-background': 'var(--mui-palette-background-default)',
'--MainNav-divider': 'var(--mui-palette-divider)',
bgcolor: 'var(--MainNav-background)',
left: 0,
position: 'sticky',
pt: { lg: 'var(--Layout-gap)' },
top: 0,
width: '100%',
zIndex: 'var(--MainNav-zIndex)',
}}
>
<Box
sx={{
borderBottom: '1px solid var(--MainNav-divider)',
display: 'flex',
flex: '1 1 auto',
minHeight: 'var(--MainNav-height)',
px: { xs: 2, lg: 3 },
py: 1,
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
<IconButton
onClick={(): void => {
setOpenNav(true);
}}
sx={{ display: { lg: 'none' } }}
>
<ListIcon />
</IconButton>
<SearchButton />
</Stack>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto', justifyContent: 'flex-end' }}
>
<NotificationsButton />
<ContactsButton />
<Divider
flexItem
orientation="vertical"
sx={{ borderColor: 'var(--MainNav-divider)', display: { xs: 'none', lg: 'block' } }}
/>
<LanguageSwitch />
<UserButton />
</Stack>
</Box>
</Box>
<MobileNav
items={items}
onClose={() => {
setOpenNav(false);
}}
open={openNav}
/>
</React.Fragment>
);
}
function SearchButton(): React.JSX.Element {
const dialog = useDialog();
return (
<React.Fragment>
<Tooltip title="Search">
<IconButton onClick={dialog.handleOpen} sx={{ display: { xs: 'none', lg: 'inline-flex' } }}>
<MagnifyingGlassIcon />
</IconButton>
</Tooltip>
<SearchDialog onClose={dialog.handleClose} open={dialog.open} />
</React.Fragment>
);
}
function ContactsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Tooltip title="Contacts">
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
<UsersIcon />
</IconButton>
</Tooltip>
<ContactsPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
function NotificationsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Tooltip title="Notifications">
<Badge
color="error"
sx={{ '& .MuiBadge-dot': { borderRadius: '50%', height: '10px', right: '6px', top: '6px', width: '10px' } }}
variant="dot"
>
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
<BellIcon />
</IconButton>
</Badge>
</Tooltip>
<NotificationsPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
function LanguageSwitch(): React.JSX.Element {
const { i18n } = useTranslation();
const popover = usePopover<HTMLButtonElement>();
const language = (i18n.language || 'en') as Language;
const flag = languageFlags[language];
return (
<React.Fragment>
<Tooltip title="Language">
<IconButton
onClick={popover.handleOpen}
ref={popover.anchorRef}
sx={{ display: { xs: 'none', lg: 'inline-flex' } }}
>
<Box sx={{ height: '24px', width: '24px' }}>
<Box alt={language} component="img" src={flag} sx={{ height: 'auto', width: '100%' }} />
</Box>
</IconButton>
</Tooltip>
<LanguagePopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
const user = {
id: 'USR-000',
name: 'Sofia Rivers',
avatar: '/assets/avatar.png',
email: 'sofia@devias.io',
} satisfies User;
function UserButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Box
component="button"
onClick={popover.handleOpen}
ref={popover.anchorRef}
sx={{ border: 'none', background: 'transparent', cursor: 'pointer', p: 0 }}
>
<Badge
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
color="success"
sx={{
'& .MuiBadge-dot': {
border: '2px solid var(--MainNav-background)',
borderRadius: '50%',
bottom: '6px',
height: '12px',
right: '6px',
width: '12px',
},
}}
variant="dot"
>
<Avatar src={user.avatar} />
</Badge>
</Box>
<UserPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}

View File

@@ -0,0 +1,3 @@
# GUIDELINE
- please keep one default `export` per file

View File

@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { Users as UsersIcon } from '@phosphor-icons/react/dist/ssr/Users';
import { usePopover } from '@/hooks/use-popover';
import { ContactsPopover } from '../../contacts-popover';
export function ContactsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Tooltip title="Contacts">
<IconButton
onClick={popover.handleOpen}
ref={popover.anchorRef}
>
<UsersIcon />
</IconButton>
</Tooltip>
<ContactsPopover
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
open={popover.open}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
import type { NavItemConfig } from '@/types/nav';
import { MobileNav } from '../../mobile-nav';
// import { NotificationsButton } from './notifications-button';
import { LanguageSwitch } from './language-switch';
import { ContactsButton } from './contacts-button';
import { SearchButton } from './search-button';
import { NotificationsButton } from './notifications-button';
import { UserButton } from './user-button';
export interface MainNavProps {
items: NavItemConfig[];
}
export function MainNav({ items }: MainNavProps): React.JSX.Element {
const [openNav, setOpenNav] = React.useState<boolean>(false);
return (
<React.Fragment>
<Box
component="header"
sx={{
'--MainNav-background': 'var(--mui-palette-background-default)',
'--MainNav-divider': 'var(--mui-palette-divider)',
bgcolor: 'var(--MainNav-background)',
left: 0,
position: 'sticky',
pt: { lg: 'var(--Layout-gap)' },
top: 0,
width: '100%',
zIndex: 'var(--MainNav-zIndex)',
}}
>
<Box
sx={{
borderBottom: '1px solid var(--MainNav-divider)',
display: 'flex',
flex: '1 1 auto',
minHeight: 'var(--MainNav-height)',
px: { xs: 2, lg: 3 },
py: 1,
}}
>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto' }}
>
<IconButton
onClick={(): void => {
setOpenNav(true);
}}
sx={{ display: { lg: 'none' } }}
>
<ListIcon />
</IconButton>
<SearchButton />
</Stack>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto', justifyContent: 'flex-end' }}
>
<NotificationsButton />
<ContactsButton />
<Divider
flexItem
orientation="vertical"
sx={{ borderColor: 'var(--MainNav-divider)', display: { xs: 'none', lg: 'block' } }}
/>
<LanguageSwitch />
<UserButton />
</Stack>
</Box>
</Box>
<MobileNav
items={items}
onClose={() => {
setOpenNav(false);
}}
open={openNav}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { useTranslation } from 'next-i18next';
import { usePopover } from '@/hooks/use-popover';
import { languageFlags, LanguagePopover } from '../../language-popover';
import type { Language } from '../../language-popover';
export function LanguageSwitch(): React.JSX.Element {
const { i18n } = useTranslation();
const popover = usePopover<HTMLButtonElement>();
const language = (i18n.language || 'en') as Language;
const flag = languageFlags[language];
return (
<React.Fragment>
<Tooltip title="Language">
<IconButton
onClick={popover.handleOpen}
ref={popover.anchorRef}
sx={{ display: { xs: 'none', lg: 'inline-flex' } }}
>
<Box sx={{ height: '24px', width: '24px' }}>
<Box
alt={language}
component="img"
src={flag}
sx={{ height: 'auto', width: '100%' }}
/>
</Box>
</IconButton>
</Tooltip>
<LanguagePopover
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
open={popover.open}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import * as React from 'react';
import Badge from '@mui/material/Badge';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { Bell as BellIcon } from '@phosphor-icons/react/dist/ssr/Bell';
import { usePopover } from '@/hooks/use-popover';
import { NotificationsPopover } from '../../notifications-popover';
import { logger } from '@/lib/default-logger';
// import { NotificationsButton } from './notifications-button';
import { toast } from '@/components/core/toaster';
import { MarkOneAsRead } from '@/db/Notifications/mark-one-as-read';
import { getUnreadNotificationsByUserId } from '@/db/Notifications/GetUnreadNotificationsByUserId';
import { Notification } from '@/db/Notifications/type';
import { useUser } from '@/hooks/use-user';
export function NotificationsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
const { user } = useUser();
const [loading, setLoading] = React.useState(true);
const [showError, setShowError] = React.useState<boolean>(false);
const [listLength, setListLength] = React.useState<number>(0);
const [notiList, setNotiList] = React.useState<Notification[]>([]);
function handleMarkAllAsRead(): void {
// try {
// await MarkOneAsRead(id);
// toast.success('Notification marked as read');
// } catch (error) {
// logger.debug(error);
// toast.error('Something went wrong');
// }
}
function handleRemoveOne(id: string, cb: () => void): void {
MarkOneAsRead(id)
.then(() => {
toast.success('Notification marked as read');
cb();
})
.catch((error) => {
logger.debug(error);
toast.error('Something went wrong');
});
}
return (
<React.Fragment>
<Tooltip title="Notifications">
<Badge
color="error"
sx={{
'& .MuiBadge-badge': {
height: '20px',
width: '20px',
right: '6px',
top: '6px',
},
}}
badgeContent={listLength}
>
<IconButton
onClick={popover.handleOpen}
ref={popover.anchorRef}
>
<BellIcon />
</IconButton>
</Badge>
</Tooltip>
{/* */}
<NotificationsPopover
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
open={popover.open}
onMarkAllAsRead={handleMarkAllAsRead}
onRemoveOne={handleRemoveOne}
setListLength={setListLength}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { useDialog } from '@/hooks/use-dialog';
import { SearchDialog } from '../../search-dialog';
export function SearchButton(): React.JSX.Element {
const dialog = useDialog();
return (
<React.Fragment>
<Tooltip title="Search">
<IconButton
onClick={dialog.handleOpen}
sx={{ display: { xs: 'none', lg: 'inline-flex' } }}
>
<MagnifyingGlassIcon />
</IconButton>
</Tooltip>
<SearchDialog
onClose={dialog.handleClose}
open={dialog.open}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import type { User } from '@/types/user';
import { usePopover } from '@/hooks/use-popover';
import { UserPopover } from '../../user-popover/user-popover';
import { useUser } from '@/hooks/use-user';
import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
// import { NotificationsButton } from './notifications-button';
// TODO:remove me
const user1 = {
id: 'USR-000',
name: 'Sofia Rivers',
avatar: '/assets/avatar.png',
email: 'sofia@devias.io',
} satisfies User;
export function UserButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
const { user, error, isLoading } = useUser();
if (!user) return <>loading</>;
return (
<React.Fragment>
<Box
component="button"
onClick={popover.handleOpen}
ref={popover.anchorRef}
sx={{ border: 'none', background: 'transparent', cursor: 'pointer', p: 0 }}
>
<Badge
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
color="success"
sx={{
'& .MuiBadge-dot': {
border: '2px solid var(--MainNav-background)',
borderRadius: '50%',
bottom: '6px',
height: '12px',
right: '6px',
width: '12px',
},
}}
variant="dot"
>
<Avatar src={getImageUrlFromFile(user.collectionId, user.id, user.avatar)} />
</Badge>
</Box>
<UserPopover
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
open={popover.open}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,9 @@
'use client';
import type { User } from '@/types/user';
export const user = {
id: 'USR-000',
name: 'Sofia Rivers',
avatar: '/assets/avatar.png',
email: 'sofia@devias.io',
} satisfies User;

View File

@@ -0,0 +1,98 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { usePathname } from 'next/navigation';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowSquareOut as ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr/ArrowSquareOut';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import { useTranslation } from 'react-i18next';
import type { NavItemConfig } from '@/types/nav';
import type { NavColor } from '@/types/settings';
import { paths } from '@/paths';
import { isNavItemActive } from '@/lib/is-nav-item-active';
import { useSettings } from '@/hooks/use-settings';
import { Logo } from '@/components/core/logo';
import type { ColorScheme } from '@/styles/theme/types';
import { icons } from '../../nav-icons';
import { WorkspacesSwitch } from '../../workspaces-switch';
import { navColorStyles } from '../styles';
import { RenderNavGroups } from './render-nav-groups';
const logoColors = {
dark: { blend_in: 'light', discrete: 'light', evident: 'light' },
light: { blend_in: 'dark', discrete: 'dark', evident: 'light' },
} as Record<ColorScheme, Record<NavColor, 'dark' | 'light'>>;
export interface SideNavProps {
color?: NavColor;
items?: NavItemConfig[];
}
export function SideNav({ color = 'evident', items = [] }: SideNavProps): React.JSX.Element {
const pathname = usePathname();
const {
settings: { colorScheme = 'light' },
} = useSettings();
const styles = navColorStyles[colorScheme][color];
const logoColor = logoColors[colorScheme][color];
return (
<Box
sx={{
...styles,
bgcolor: 'var(--SideNav-background)',
borderRight: 'var(--SideNav-border)',
color: 'var(--SideNav-color)',
display: { xs: 'none', lg: 'flex' },
flexDirection: 'column',
height: '100%',
left: 0,
position: 'fixed',
top: 0,
width: 'var(--SideNav-width)',
zIndex: 'var(--SideNav-zIndex)',
}}
>
<Stack
spacing={2}
sx={{ p: 2 }}
>
<div>
<Box
component={RouterLink}
href={paths.home}
sx={{ display: 'inline-flex' }}
>
<Logo
color={logoColor}
height={32}
width={122}
/>
</Box>
</div>
<WorkspacesSwitch />
</Stack>
<Box
component="nav"
sx={{
flex: '1 1 auto',
overflowY: 'auto',
p: 2,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
}}
>
{RenderNavGroups({ items, pathname })}
</Box>
</Box>
);
}

View File

@@ -2,10 +2,8 @@
import * as React from 'react'; import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import { usePathname } from 'next/navigation';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { ArrowSquareOut as ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr/ArrowSquareOut'; import { ArrowSquareOut as ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr/ArrowSquareOut';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
@@ -13,136 +11,9 @@ import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/Car
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { NavItemConfig } from '@/types/nav'; import type { NavItemConfig } from '@/types/nav';
import type { NavColor } from '@/types/settings';
import { paths } from '@/paths';
import { isNavItemActive } from '@/lib/is-nav-item-active'; import { isNavItemActive } from '@/lib/is-nav-item-active';
import { useSettings } from '@/hooks/use-settings';
import { Logo } from '@/components/core/logo';
import type { ColorScheme } from '@/styles/theme/types';
import { icons } from '../nav-icons'; import { icons } from '../../nav-icons';
import { WorkspacesSwitch } from '../workspaces-switch';
import { navColorStyles } from './styles';
const logoColors = {
dark: { blend_in: 'light', discrete: 'light', evident: 'light' },
light: { blend_in: 'dark', discrete: 'dark', evident: 'light' },
} as Record<ColorScheme, Record<NavColor, 'dark' | 'light'>>;
export interface SideNavProps {
color?: NavColor;
items?: NavItemConfig[];
}
export function SideNav({ color = 'evident', items = [] }: SideNavProps): React.JSX.Element {
const pathname = usePathname();
const {
settings: { colorScheme = 'light' },
} = useSettings();
const styles = navColorStyles[colorScheme][color];
const logoColor = logoColors[colorScheme][color];
return (
<Box
sx={{
...styles,
bgcolor: 'var(--SideNav-background)',
borderRight: 'var(--SideNav-border)',
color: 'var(--SideNav-color)',
display: { xs: 'none', lg: 'flex' },
flexDirection: 'column',
height: '100%',
left: 0,
position: 'fixed',
top: 0,
width: 'var(--SideNav-width)',
zIndex: 'var(--SideNav-zIndex)',
}}
>
<Stack spacing={2} sx={{ p: 2 }}>
<div>
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-flex' }}>
<Logo color={logoColor} height={32} width={122} />
</Box>
</div>
<WorkspacesSwitch />
</Stack>
<Box
component="nav"
sx={{
flex: '1 1 auto',
overflowY: 'auto',
p: 2,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
}}
>
{RenderNavGroups({ items, pathname })}
</Box>
</Box>
);
}
function RenderNavGroups({ items, pathname }: { items: NavItemConfig[]; pathname: string }): React.JSX.Element {
const { t } = useTranslation();
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
acc.push(
<Stack component="li" key={curr.key} spacing={1.5}>
{curr.title ? (
<div>
<Typography sx={{ color: 'var(--NavGroup-title-color)', fontSize: '0.875rem', fontWeight: 500 }}>
{t(curr.title)}
</Typography>
</div>
) : null}
<div>{renderNavItems({ depth: 0, items: curr.items, pathname })}</div>
</Stack>
);
return acc;
}, []);
return (
<Stack component="ul" spacing={2} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
function renderNavItems({
depth = 0,
items = [],
pathname,
}: {
depth: number;
items?: NavItemConfig[];
pathname: string;
}): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { items: childItems, key, ...item } = curr;
const forceOpen = childItems
? Boolean(childItems.find((childItem) => childItem.href && pathname.startsWith(childItem.href)))
: false;
acc.push(
<NavItem depth={depth} forceOpen={forceOpen} key={key} pathname={pathname} {...item}>
{childItems ? renderNavItems({ depth: depth + 1, pathname, items: childItems }) : null}
</NavItem>
);
return acc;
}, []);
return (
<Stack component="ul" data-depth={depth} spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
interface NavItemProps extends Omit<NavItemConfig, 'items'> { interface NavItemProps extends Omit<NavItemConfig, 'items'> {
children?: React.ReactNode; children?: React.ReactNode;
@@ -151,7 +22,7 @@ interface NavItemProps extends Omit<NavItemConfig, 'items'> {
pathname: string; pathname: string;
} }
function NavItem({ export function NavItem({
children, children,
depth, depth,
disabled, disabled,
@@ -173,7 +44,11 @@ function NavItem({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box component="li" data-depth={depth} sx={{ userSelect: 'none' }}> <Box
component="li"
data-depth={depth}
sx={{ userSelect: 'none' }}
>
<Box <Box
{...(isBranch {...(isBranch
? { ? {
@@ -254,15 +129,27 @@ function NavItem({
{t(title || '')} {t(title || '')}
</Typography> </Typography>
</Box> </Box>
{label ? <Chip color="primary" label={label} size="small" /> : null} {label ? (
<Chip
color="primary"
label={label}
size="small"
/>
) : null}
{external ? ( {external ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}> <Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ArrowSquareOutIcon color="var(--NavItem-icon-color)" fontSize="var(--icon-fontSize-sm)" /> <ArrowSquareOutIcon
color="var(--NavItem-icon-color)"
fontSize="var(--icon-fontSize-sm)"
/>
</Box> </Box>
) : null} ) : null}
{isBranch ? ( {isBranch ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}> <Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ExpandIcon color="var(--NavItem-expand-color)" fontSize="var(--icon-fontSize-sm)" /> <ExpandIcon
color="var(--NavItem-expand-color)"
fontSize="var(--icon-fontSize-sm)"
/>
</Box> </Box>
) : null} ) : null}
</Box> </Box>

View File

@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import type { NavItemConfig } from '@/types/nav';
import { renderNavItems } from './render-nav-items';
export function RenderNavGroups({ items, pathname }: { items: NavItemConfig[]; pathname: string }): React.JSX.Element {
const { t } = useTranslation();
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
acc.push(
<Stack
component="li"
key={curr.key}
spacing={1.5}
>
{curr.title ? (
<div>
<Typography sx={{ color: 'var(--NavGroup-title-color)', fontSize: '0.875rem', fontWeight: 500 }}>
{t(curr.title)}
</Typography>
</div>
) : null}
<div>{renderNavItems({ depth: 0, items: curr.items, pathname })}</div>
</Stack>
);
return acc;
}, []);
return (
<Stack
component="ul"
spacing={2}
sx={{ listStyle: 'none', m: 0, p: 0 }}
>
{children}
</Stack>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import * as React from 'react';
import Stack from '@mui/material/Stack';
import type { NavItemConfig } from '@/types/nav';
import { NavItem } from './nav-item';
export function renderNavItems({
depth = 0,
items = [],
pathname,
}: {
depth: number;
items?: NavItemConfig[];
pathname: string;
}): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { items: childItems, key, ...item } = curr;
const forceOpen = childItems
? Boolean(childItems.find((childItem) => childItem.href && pathname.startsWith(childItem.href)))
: false;
acc.push(
<NavItem
depth={depth}
forceOpen={forceOpen}
key={key}
pathname={pathname}
{...item}
>
{childItems ? renderNavItems({ depth: depth + 1, pathname, items: childItems }) : null}
</NavItem>
);
return acc;
}, []);
return (
<Stack
component="ul"
data-depth={depth}
spacing={1}
sx={{ listStyle: 'none', m: 0, p: 0 }}
>
{children}
</Stack>
);
}

View File

@@ -1,138 +0,0 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import InputAdornment from '@mui/material/InputAdornment';
import InputLabel from '@mui/material/InputLabel';
import Link from '@mui/material/Link';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
import { Option } from '@/components/core/option';
export function AccountDetails(): React.JSX.Element {
return (
<Card>
<CardHeader
avatar={
<Avatar>
<UserIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Basic details"
/>
<CardContent>
<Stack spacing={3}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%',
display: 'inline-flex',
p: '4px',
}}
>
<Box sx={{ borderRadius: 'inherit', position: 'relative' }}>
<Box
sx={{
alignItems: 'center',
bgcolor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 'inherit',
bottom: 0,
color: 'var(--mui-palette-common-white)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
left: 0,
opacity: 0,
position: 'absolute',
right: 0,
top: 0,
zIndex: 1,
'&:hover': { opacity: 1 },
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<CameraIcon fontSize="var(--icon-fontSize-md)" />
<Typography color="inherit" variant="subtitle2">
Select
</Typography>
</Stack>
</Box>
<Avatar src="/assets/avatar.png" sx={{ '--Avatar-size': '100px' }} />
</Box>
</Box>
<Button color="secondary" size="small">
Remove
</Button>
</Stack>
<Stack spacing={2}>
<FormControl>
<InputLabel>Full name</InputLabel>
<OutlinedInput defaultValue="Sofia Rivers" name="fullName" />
</FormControl>
<FormControl disabled>
<InputLabel>Email address</InputLabel>
<OutlinedInput name="email" type="email" value="sofia@devias.io" />
<FormHelperText>
Please <Link variant="inherit">contact us</Link> to change your email
</FormHelperText>
</FormControl>
<Stack direction="row" spacing={2}>
<FormControl sx={{ width: '160px' }}>
<InputLabel>Dial code</InputLabel>
<Select
name="countryCode"
startAdornment={
<InputAdornment position="start">
<Box
alt="Spain"
component="img"
src="/assets/flag-es.svg"
sx={{ display: 'block', height: '20px', width: 'auto' }}
/>
</InputAdornment>
}
value="+34"
>
<Option value="+1">United States</Option>
<Option value="+49">Germany</Option>
<Option value="+34">Spain</Option>
</Select>
</FormControl>
<FormControl sx={{ flex: '1 1 auto' }}>
<InputLabel>Phone number</InputLabel>
<OutlinedInput defaultValue="965 245 7623" name="phone" />
</FormControl>
</Stack>
<FormControl>
<InputLabel>Title</InputLabel>
<OutlinedInput name="title" placeholder="e.g Golang Developer" />
</FormControl>
<FormControl>
<InputLabel>Biography (optional)</InputLabel>
<OutlinedInput name="bio" placeholder="Describe yourself..." />
<FormHelperText>0/200 characters</FormHelperText>
</FormControl>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary">Cancel</Button>
<Button variant="contained">Save changes</Button>
</CardActions>
</Card>
);
}

View File

@@ -0,0 +1,3 @@
# GUIDELINES
- use i18n

View File

@@ -0,0 +1,348 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { COL_USER_METAS } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import InputAdornment from '@mui/material/InputAdornment';
import InputLabel from '@mui/material/InputLabel';
import Link from '@mui/material/Link';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { fileToBase64 } from '@/lib/file-to-base64';
import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { pb } from '@/lib/pb';
import { useUser } from '@/hooks/use-user';
import { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
import ErrorDisplay from '../../error';
const schema = zod.object({
name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
phone: zod.string().min(1, 'Phone is required').max(25),
company: zod.string().max(255).optional(),
billingAddress: zod.object({
country: zod.string().min(1, 'Country is required').max(255),
state: zod.string().min(1, 'State is required').max(255),
city: zod.string().min(1, 'City is required').max(255),
zipCode: zod.string().min(1, 'Zip code is required').max(255),
line1: zod.string().min(1, 'Street line 1 is required').max(255),
line2: zod.string().max(255).optional(),
}),
taxId: zod.string().max(255).optional(),
timezone: zod.string().min(1, 'Timezone is required').max(255),
language: zod.string().min(1, 'Language is required').max(255),
currency: zod.string().min(1, 'Currency is required').max(255),
avatar: zod.string().optional(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
name: '',
email: '',
phone: '',
company: '',
billingAddress: {
country: '',
state: '',
city: '',
zipCode: '',
line1: '',
line2: '',
},
taxId: '',
timezone: '',
language: '',
currency: '',
avatar: '',
} satisfies Values;
export function AccountDetails(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation();
const { user, isLoading } = useUser();
const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
setIsUpdating(true);
// const updateData = {
// name: values.name,
// email: values.email,
// phone: values.phone,
// company: values.company,
// billingAddress: values.billingAddress,
// taxId: values.taxId,
// timezone: values.timezone,
// language: values.language,
// currency: values.currency,
// avatar: values.avatar ? await base64ToFile(values.avatar) : null,
// };
// try {
// await pb.collection(COL_CUSTOMERS).update(customerId, updateData);
// toast.success('Customer updated successfully');
// router.push(paths.dashboard.students.list);
// } catch (error) {
// logger.error(error);
// toast.error('Failed to update customer');
// } finally {
// setIsUpdating(false);
// }
},
[router]
);
const {
control,
handleSubmit,
formState: { errors },
setValue,
reset,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const loadExistingData = React.useCallback(async () => {
setShowLoading(true);
if (user) {
try {
const result = await pb.collection(COL_USER_METAS).getOne(user.id);
reset({ ...defaultValues, ...result });
console.log({ result });
if (result.avatar) {
// TODO: remove me
// const fetchResult = await fetch(
// `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar}`
// );
const fetchResult = await fetch(getImageUrlFromFile(result.collectionId, result.id, result.avatar));
const blob = await fetchResult.blob();
const url = await fileToBase64(blob);
setValue('avatar', url);
}
} catch (error) {
logger.error(error);
// TODO: add i18n here
toast.error('Failed to load customer data');
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
}
}
}, [user, reset, setValue]);
React.useEffect(() => {
void loadExistingData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (showLoading) return <FormLoading />;
if (!user) return <>loading</>;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardHeader
avatar={
<Avatar>
<UserIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Basic details"
/>
<CardContent>
<Stack spacing={3}>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%',
display: 'inline-flex',
p: '4px',
}}
>
<Box sx={{ borderRadius: 'inherit', position: 'relative' }}>
<Box
sx={{
alignItems: 'center',
bgcolor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 'inherit',
bottom: 0,
color: 'var(--mui-palette-common-white)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
left: 0,
opacity: 0,
position: 'absolute',
right: 0,
top: 0,
zIndex: 1,
'&:hover': { opacity: 1 },
}}
>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center' }}
>
<CameraIcon fontSize="var(--icon-fontSize-md)" />
<Typography
color="inherit"
variant="subtitle2"
>
{t('select')}
</Typography>
</Stack>
</Box>
<Avatar
src={getImageUrlFromFile(user.collectionId, user.id, user.avatar)}
sx={{ '--Avatar-size': '100px' }}
/>
</Box>
</Box>
<Button
color="secondary"
size="small"
>
{t('remove')}
</Button>
</Stack>
<Stack spacing={2}>
<Controller
control={control}
name="name"
render={({ field }) => (
<FormControl
error={Boolean(errors.name)}
fullWidth
>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
<Controller
disabled
control={control}
name="email"
render={({ field }) => (
<FormControl
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email</InputLabel>
<OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
<Stack
direction="row"
spacing={2}
>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Stack>
<FormControl>
<InputLabel>Title</InputLabel>
<OutlinedInput
name="title"
placeholder="e.g Golang Developer"
/>
</FormControl>
<FormControl>
<InputLabel>Biography (optional)</InputLabel>
<OutlinedInput
name="bio"
placeholder="Describe yourself..."
/>
<FormHelperText>0/200 characters</FormHelperText>
</FormControl>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.overview}
>
{t('edit.cancelButton')}
</Button>
{/* <Button variant="contained">Save changes</Button> */}
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
{t('edit.updateButton')}
</LoadingButton>
</CardActions>
</Card>
</form>
);
}

View File

@@ -20,11 +20,17 @@ export function DeleteAccount(): React.JSX.Element {
title="Delete account" title="Delete account"
/> />
<CardContent> <CardContent>
<Stack spacing={3} sx={{ alignItems: 'flex-start' }}> <Stack
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1"> <Typography variant="subtitle1">
Delete your account and all of your source data. This is irreversible. Delete your account and all of your source data. This is irreversible.
</Typography> </Typography>
<Button color="error" variant="outlined"> <Button
color="error"
variant="outlined"
>
Delete account Delete account
</Button> </Button>
</Stack> </Stack>

View File

@@ -0,0 +1,116 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { COL_USER_METAS } from '@/constants';
import { LoadingButton } from '@mui/lab';
import Avatar from '@mui/material/Avatar';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { fileToBase64 } from '@/lib/file-to-base64';
import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { pb } from '@/lib/pb';
import { useUser } from '@/hooks/use-user';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
import ErrorDisplay from '../../error';
import { NavItem } from './nav-item';
import { navItems } from './navItems';
export function SideNav(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation();
const { user, isLoading } = useUser();
const pathname = usePathname();
const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
if (showLoading) return <FormLoading />;
if (!user) return <>loading</>;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<div>
<Stack
spacing={3}
sx={{
flex: '0 0 auto',
flexDirection: { xs: 'column-reverse', md: 'column' },
position: { md: 'sticky' },
top: '64px',
width: { xs: '100%', md: '240px' },
}}
>
<Stack
component="ul"
spacing={3}
sx={{ listStyle: 'none', m: 0, p: 0 }}
>
{navItems.map((group) => (
<Stack
component="li"
key={group.key}
spacing={2}
>
{group.title ? (
<div>
<Typography
color="text.secondary"
variant="caption"
>
{group.title}
</Typography>
</div>
) : null}
<Stack
component="ul"
spacing={1}
sx={{ listStyle: 'none', m: 0, p: 0 }}
>
{group.items.map((item) => (
<NavItem
{...item}
key={item.key}
pathname={pathname}
/>
))}
</Stack>
</Stack>
))}
</Stack>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<Avatar src={getImageUrlFromFile(user.collectionId, user.id, user.avatar)}>{user.name}</Avatar>
<div>
<Typography variant="subtitle1">{user.name}</Typography>
<Typography
color="text.secondary"
variant="caption"
>
{user.email}
</Typography>
</div>
</Stack>
</Stack>
</div>
);
}

View File

@@ -16,36 +16,9 @@ import { UserCircle as UserCircleIcon } from '@phosphor-icons/react/dist/ssr/Use
import { UsersThree as UsersThreeIcon } from '@phosphor-icons/react/dist/ssr/UsersThree'; import { UsersThree as UsersThreeIcon } from '@phosphor-icons/react/dist/ssr/UsersThree';
import type { NavItemConfig } from '@/types/nav'; import type { NavItemConfig } from '@/types/nav';
import { paths } from '@/paths';
import { isNavItemActive } from '@/lib/is-nav-item-active'; import { isNavItemActive } from '@/lib/is-nav-item-active';
// NOTE: First level elements are groups. import { navItems } from './navItems';
const navItems = [
{
key: 'personal',
title: 'Personal',
items: [
{ key: 'account', title: 'Account', href: paths.dashboard.settings.account, icon: 'user-circle' },
{ key: 'notifications', title: 'Notifications', href: paths.dashboard.settings.notifications, icon: 'bell' },
{ key: 'security', title: 'Security', href: paths.dashboard.settings.security, icon: 'lock-key' },
],
},
{
key: 'organization',
title: 'Organization',
items: [
{ key: 'billing', title: 'Billing & plans', href: paths.dashboard.settings.billing, icon: 'credit-card' },
{ key: 'team', title: 'Team', href: paths.dashboard.settings.team, icon: 'users-three' },
{
key: 'integrations',
title: 'Integrations',
href: paths.dashboard.settings.integrations,
icon: 'plugs-connected',
},
],
},
] satisfies NavItemConfig[];
const icons = { const icons = {
'credit-card': CreditCardIcon, 'credit-card': CreditCardIcon,
@@ -56,63 +29,19 @@ const icons = {
bell: BellIcon, bell: BellIcon,
} as Record<string, Icon>; } as Record<string, Icon>;
export function SideNav(): React.JSX.Element {
const pathname = usePathname();
return (
<div>
<Stack
spacing={3}
sx={{
flex: '0 0 auto',
flexDirection: { xs: 'column-reverse', md: 'column' },
position: { md: 'sticky' },
top: '64px',
width: { xs: '100%', md: '240px' },
}}
>
<Stack component="ul" spacing={3} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{navItems.map((group) => (
<Stack component="li" key={group.key} spacing={2}>
{group.title ? (
<div>
<Typography color="text.secondary" variant="caption">
{group.title}
</Typography>
</div>
) : null}
<Stack component="ul" spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{group.items.map((item) => (
<NavItem {...item} key={item.key} pathname={pathname} />
))}
</Stack>
</Stack>
))}
</Stack>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Avatar src="/assets/avatar.png">AV</Avatar>
<div>
<Typography variant="subtitle1">Sofia Rivers</Typography>
<Typography color="text.secondary" variant="caption">
sofia@devias.io
</Typography>
</div>
</Stack>
</Stack>
</div>
);
}
interface NavItemProps extends NavItemConfig { interface NavItemProps extends NavItemConfig {
pathname: string; pathname: string;
} }
function NavItem({ disabled, external, href, icon, pathname, title }: NavItemProps): React.JSX.Element { export function NavItem({ disabled, external, href, icon, pathname, title }: NavItemProps): React.JSX.Element {
const active = isNavItemActive({ disabled, external, href, pathname }); const active = isNavItemActive({ disabled, external, href, pathname });
const Icon = icon ? icons[icon] : null; const Icon = icon ? icons[icon] : null;
return ( return (
<Box component="li" sx={{ userSelect: 'none' }}> <Box
component="li"
sx={{ userSelect: 'none' }}
>
<Box <Box
{...(href {...(href
? { ? {

View File

@@ -0,0 +1,33 @@
'use client';
import type { NavItemConfig } from '@/types/nav';
import { paths } from '@/paths';
// NOTE: First level elements are groups.
const navItems = [
{
key: 'personal',
title: 'Personal',
items: [
{ key: 'account', title: 'Account', href: paths.dashboard.settings.account, icon: 'user-circle' },
{ key: 'notifications', title: 'Notifications', href: paths.dashboard.settings.notifications, icon: 'bell' },
{ key: 'security', title: 'Security', href: paths.dashboard.settings.security, icon: 'lock-key' },
],
},
{
key: 'organization',
title: 'Organization',
items: [
{ key: 'billing', title: 'Billing & plans', href: paths.dashboard.settings.billing, icon: 'credit-card' },
{ key: 'team', title: 'Team', href: paths.dashboard.settings.team, icon: 'users-three' },
{
key: 'integrations',
title: 'Integrations',
href: paths.dashboard.settings.integrations,
icon: 'plugs-connected',
},
],
},
] satisfies NavItemConfig[];
export { navItems };

View File

@@ -1,9 +1,17 @@
'use client'; 'use client';
// src/components/dashboard/student/student-create-form.tsx
//
import * as React from 'react'; import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { createStudent } from '@/db/Students/Create';
import { getStudentById } from '@/db/Students/GetById';
import { UpdateStudentById } from '@/db/Students/UpdateById';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
@@ -16,41 +24,38 @@ import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText'; import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel'; import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import OutlinedInput from '@mui/material/OutlinedInput'; import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select'; import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2'; import Grid from '@mui/material/Unstable_Grid2';
//
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
//
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod'; import { z as zod } from 'zod';
import { paths } from '@/paths'; import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import { Option } from '@/components/core/option'; import { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster'; import { toast } from '@/components/core/toaster';
import { createCustomer } from '@/db/Customers/Create'; import FormLoading from '@/components/loading';
import isDevelopment from '@/lib/check-is-development';
function fileToBase64(file: Blob): Promise<string> { // import ErrorDisplay from '../../error';
return new Promise((resolve, reject) => { import ErrorDisplay from '../error';
const reader = new FileReader(); import { CreateFormProps, Student } from './type.d';
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = () => {
reject(new Error('Error converting file to base64'));
};
});
}
// TODO: review schema
const schema = zod.object({ const schema = zod.object({
avatar: zod.string().optional(),
name: zod.string().min(1, 'Name is required').max(255), name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255), email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
phone: zod.string().min(1, 'Phone is required').max(15), phone: zod.string().min(1, 'Phone is required').max(25),
company: zod.string().max(255), company: zod.string().max(255).optional(),
billingAddress: zod.object({ billingAddress: zod.object({
country: zod.string().min(1, 'Country is required').max(255), country: zod.string().min(1, 'Country is required').max(255),
state: zod.string().min(1, 'State is required').max(255), state: zod.string().min(1, 'State is required').max(255),
@@ -63,12 +68,12 @@ const schema = zod.object({
timezone: zod.string().min(1, 'Timezone is required').max(255), timezone: zod.string().min(1, 'Timezone is required').max(255),
language: zod.string().min(1, 'Language is required').max(255), language: zod.string().min(1, 'Language is required').max(255),
currency: zod.string().min(1, 'Currency is required').max(255), currency: zod.string().min(1, 'Currency is required').max(255),
avatar: zod.string().optional(),
}); });
type Values = zod.infer<typeof schema>; type Values = zod.infer<typeof schema>;
const defaultValues = { const defaultValues = {
avatar: '',
name: 'new name', name: 'new name',
email: '123@123.com', email: '123@123.com',
phone: '91234567', phone: '91234567',
@@ -85,10 +90,18 @@ const defaultValues = {
timezone: 'new_york', timezone: 'new_york',
language: 'en', language: 'en',
currency: 'USD', currency: 'USD',
avatar: '',
} satisfies Values; } satisfies Values;
export function CustomerCreateForm(): React.JSX.Element { export function StudentCreateForm(): React.JSX.Element {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(['students']);
//
const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const { const {
control, control,
@@ -100,14 +113,35 @@ export function CustomerCreateForm(): React.JSX.Element {
const onSubmit = React.useCallback( const onSubmit = React.useCallback(
async (values: Values): Promise<void> => { async (values: Values): Promise<void> => {
// Use standard create method from db/Customers/Create
const tempCreate: CreateFormProps = {
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
//
name: values.name,
email: values.email,
phone: values.phone,
company: values.company,
timezone: values.timezone,
language: values.language,
currency: values.currency,
taxId: values.taxId,
state: 'pending',
meta: {},
};
try { try {
// Use standard create method from db/Customers/Create // if (billingAddressId) {
const record = await createCustomer(values); // await UpdateBillingAddressById(billingAddressId, values.billingAddress);
toast.success('Customer created'); // }
router.push(paths.dashboard.students.view(record.id));
const record = await createStudent(tempCreate);
toast.success('Student created');
// router.push(paths.dashboard.students.view(record.id));
} catch (err) { } catch (err) {
logger.error(err); logger.error(err);
toast.error('Failed to create customer'); toast.error('Failed to create Student');
} finally {
setIsUpdating(false);
} }
}, },
[router] [router]
@@ -137,7 +171,7 @@ export function CustomerCreateForm(): React.JSX.Element {
spacing={4} spacing={4}
> >
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Account information</Typography> <Typography variant="h6">{t('create.basic-info')}</Typography>
<Grid <Grid
container container
spacing={3} spacing={3}
@@ -151,12 +185,13 @@ export function CustomerCreateForm(): React.JSX.Element {
<Box <Box
sx={{ sx={{
border: '1px dashed var(--mui-palette-divider)', border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%', borderRadius: '5%',
display: 'inline-flex', display: 'inline-flex',
p: '4px', p: '4px',
}} }}
> >
<Avatar <Avatar
variant="rounded"
src={avatar} src={avatar}
sx={{ sx={{
'--Avatar-size': '100px', '--Avatar-size': '100px',
@@ -175,8 +210,8 @@ export function CustomerCreateForm(): React.JSX.Element {
spacing={1} spacing={1}
sx={{ alignItems: 'flex-start' }} sx={{ alignItems: 'flex-start' }}
> >
<Typography variant="subtitle1">Avatar</Typography> <Typography variant="subtitle1">{t('create.avatar')}</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography> <Typography variant="caption">{t('create.avatarRequirements')}</Typography>
<Button <Button
color="secondary" color="secondary"
onClick={() => { onClick={() => {
@@ -184,7 +219,7 @@ export function CustomerCreateForm(): React.JSX.Element {
}} }}
variant="outlined" variant="outlined"
> >
Select {t('create.avatar_select')}
</Button> </Button>
<input <input
hidden hidden
@@ -226,7 +261,7 @@ export function CustomerCreateForm(): React.JSX.Element {
error={Boolean(errors.email)} error={Boolean(errors.email)}
fullWidth fullWidth
> >
<InputLabel required>Email address</InputLabel> <InputLabel required>{t('create.email-address')}</InputLabel>
<OutlinedInput <OutlinedInput
{...field} {...field}
type="email" type="email"
@@ -248,7 +283,7 @@ export function CustomerCreateForm(): React.JSX.Element {
error={Boolean(errors.phone)} error={Boolean(errors.phone)}
fullWidth fullWidth
> >
<InputLabel required>Phone number</InputLabel> <InputLabel required>{t('create.phone-number')}</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null} {errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl> </FormControl>
@@ -268,7 +303,10 @@ export function CustomerCreateForm(): React.JSX.Element {
fullWidth fullWidth
> >
<InputLabel>Company</InputLabel> <InputLabel>Company</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput
{...field}
placeholder="no company name"
/>
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null} {errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
@@ -276,8 +314,9 @@ export function CustomerCreateForm(): React.JSX.Element {
</Grid> </Grid>
</Grid> </Grid>
</Stack> </Stack>
{/* */}
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Billing information</Typography> <Typography variant="h6">{t('create.billing-information')}</Typography>
<Grid <Grid
container container
spacing={3} spacing={3}
@@ -296,10 +335,12 @@ export function CustomerCreateForm(): React.JSX.Element {
> >
<InputLabel required>Country</InputLabel> <InputLabel required>Country</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Choose a country</Option> <MenuItem value="">Choose a country</MenuItem>
<Option value="us">United States</Option> <MenuItem value="US">United States</MenuItem>
<Option value="de">Germany</Option> <MenuItem value="UK">United Kingdom</MenuItem>
<Option value="es">Spain</Option> <MenuItem value="CA">Canada</MenuItem>
<MenuItem value="DE">Germany</MenuItem>
<MenuItem value="ES">Spain</MenuItem>
</Select> </Select>
{errors.billingAddress?.country ? ( {errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText> <FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText>
@@ -362,7 +403,7 @@ export function CustomerCreateForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.zipCode)} error={Boolean(errors.billingAddress?.zipCode)}
fullWidth fullWidth
> >
<InputLabel required>Zip code</InputLabel> <InputLabel required>{t('create.zip-code')}</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? ( {errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText> <FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText>
@@ -383,7 +424,7 @@ export function CustomerCreateForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.line1)} error={Boolean(errors.billingAddress?.line1)}
fullWidth fullWidth
> >
<InputLabel required>Address</InputLabel> <InputLabel required>{t('create.address-line-1')}</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.line1 ? ( {errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText> <FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText>
@@ -424,7 +465,7 @@ export function CustomerCreateForm(): React.JSX.Element {
/> />
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Additional information</Typography> <Typography variant="h6">{t('create.additional-information')}</Typography>
<Grid <Grid
container container
spacing={3} spacing={3}
@@ -443,10 +484,14 @@ export function CustomerCreateForm(): React.JSX.Element {
> >
<InputLabel required>Timezone</InputLabel> <InputLabel required>Timezone</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a timezone</Option> <MenuItem value="">Select a timezone</MenuItem>
<Option value="new_york">US - New York</Option> <MenuItem value="Europe/London">London</MenuItem>
<Option value="california">US - California</Option> <MenuItem value="Asia/Tokyo">Tokyo</MenuItem>
<Option value="london">UK - London</Option> <MenuItem value="America/Boa_Vista">Boa Vista</MenuItem>
<MenuItem value="America/Grand_Turk">Grand Turk</MenuItem>
<MenuItem value="Asia/Manila">Manila</MenuItem>
<MenuItem value="Asia/Urumqi">Urumqi</MenuItem>
<MenuItem value="Africa/Tunis">Tunis</MenuItem>
</Select> </Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null} {errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl> </FormControl>
@@ -467,10 +512,11 @@ export function CustomerCreateForm(): React.JSX.Element {
> >
<InputLabel required>Language</InputLabel> <InputLabel required>Language</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a language</Option> <MenuItem value="">Select a language</MenuItem>
<Option value="en">English</Option> <MenuItem value="en">English</MenuItem>
<Option value="es">Spanish</Option> <MenuItem value="es">Spanish</MenuItem>
<Option value="de">German</Option> <MenuItem value="de">German</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select> </Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null} {errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl> </FormControl>
@@ -489,12 +535,12 @@ export function CustomerCreateForm(): React.JSX.Element {
error={Boolean(errors.currency)} error={Boolean(errors.currency)}
fullWidth fullWidth
> >
<InputLabel>Currency</InputLabel> <InputLabel required>{t('create.currency')}</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a currency</Option> <MenuItem value="">no currency selected</MenuItem>
<Option value="USD">USD</Option> <MenuItem value="USD">USD</MenuItem>
<Option value="EUR">EUR</Option> <MenuItem value="EUR">EUR</MenuItem>
<Option value="RON">RON</Option> <MenuItem value="GBP">GBP</MenuItem>
</Select> </Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null} {errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl> </FormControl>
@@ -511,14 +557,17 @@ export function CustomerCreateForm(): React.JSX.Element {
component={RouterLink} component={RouterLink}
href={paths.dashboard.students.list} href={paths.dashboard.students.list}
> >
Cancel {t('create.cancelButton')}
</Button> </Button>
<Button
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit" type="submit"
variant="contained" variant="contained"
> >
Create customer {t('create.updateButton')}
</Button> </LoadingButton>
</CardActions> </CardActions>
</Card> </Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}> <Box sx={{ display: isDevelopment ? 'block' : 'none' }}>

View File

@@ -1,10 +1,15 @@
'use client'; 'use client';
// src/components/dashboard/student/student-edit-form.tsx
//
import * as React from 'react'; import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
// //
import { COL_CUSTOMERS } from '@/constants'; import { COL_CUSTOMERS, COL_USER_METAS } from '@/constants';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { getStudentById } from '@/db/Students/GetById';
import { UpdateStudentById } from '@/db/Students/UpdateById';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab'; import { LoadingButton } from '@mui/lab';
// //
@@ -32,6 +37,7 @@ import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod'; import { z as zod } from 'zod';
import { paths } from '@/paths'; import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
@@ -40,9 +46,8 @@ import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error'; // import ErrorDisplay from '../../error';
import ErrorDisplay from '../error'; import ErrorDisplay from '../error';
import isDevelopment from '@/lib/check-is-development';
// TODO: review this // TODO: review schema
const schema = zod.object({ const schema = zod.object({
name: zod.string().min(1, 'Name is required').max(255), name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255), email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
@@ -87,14 +92,15 @@ const defaultValues = {
export function StudentEditForm(): React.JSX.Element { export function StudentEditForm(): React.JSX.Element {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(['lp_categories']); const { t } = useTranslation(['students']);
const { customerId } = useParams<{ customerId: string }>(); const { id: studentId } = useParams<{ id: string }>();
// //
const [isUpdating, setIsUpdating] = React.useState<boolean>(false); const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false); const [showLoading, setShowLoading] = React.useState<boolean>(false);
// //
const [showError, setShowError] = React.useState({ show: false, detail: '' }); const [showError, setShowError] = React.useState({ show: false, detail: '' });
const [billingAddressId, setBillingAddressId] = React.useState<string | null>(null);
const { const {
control, control,
@@ -110,30 +116,38 @@ export function StudentEditForm(): React.JSX.Element {
setIsUpdating(true); setIsUpdating(true);
const updateData = { const updateData = {
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
//
name: values.name, name: values.name,
email: values.email, email: values.email,
phone: values.phone, phone: values.phone,
company: values.company, company: values.company,
billingAddress: values.billingAddress, //
taxId: values.taxId, // billingAddress: values.billingAddress,
//
timezone: values.timezone, timezone: values.timezone,
language: values.language, language: values.language,
currency: values.currency, currency: values.currency,
avatar: values.avatar ? await base64ToFile(values.avatar) : null, taxId: values.taxId,
}; };
try { try {
await pb.collection(COL_CUSTOMERS).update(customerId, updateData); // await pb.collection(COL_USER_METAS).update(studentId, updateData);
toast.success('Customer updated successfully'); await UpdateStudentById(studentId, updateData);
toast.success('Student updated successfully');
router.push(paths.dashboard.students.list); router.push(paths.dashboard.students.list);
if (billingAddressId) {
await UpdateBillingAddressById(billingAddressId, values.billingAddress);
}
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
toast.error('Failed to update customer'); toast.error('Failed to update student');
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
}, },
[customerId, router] [studentId, router]
); );
const avatarInputRef = React.useRef<HTMLInputElement>(null); const avatarInputRef = React.useRef<HTMLInputElement>(null);
@@ -162,13 +176,14 @@ export function StudentEditForm(): React.JSX.Element {
setShowLoading(true); setShowLoading(true);
try { try {
const result = await pb.collection(COL_CUSTOMERS).getOne(id); const result = await getStudentById(id);
reset({ ...defaultValues, ...result }); reset({ ...defaultValues, ...result });
console.log({ result });
if (result.avatar_file) { setBillingAddressId(result.billingAddress.id);
if (result.avatar) {
const fetchResult = await fetch( const fetchResult = await fetch(
`http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar_file}` `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar}`
); );
const blob = await fetchResult.blob(); const blob = await fetchResult.blob();
const url = await fileToBase64(blob); const url = await fileToBase64(blob);
@@ -176,7 +191,7 @@ export function StudentEditForm(): React.JSX.Element {
} }
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
toast.error('Failed to load customer data'); toast.error('Failed to load student data');
setShowError({ show: true, detail: JSON.stringify(error, null, 2) }); setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally { } finally {
setShowLoading(false); setShowLoading(false);
@@ -186,9 +201,9 @@ export function StudentEditForm(): React.JSX.Element {
); );
React.useEffect(() => { React.useEffect(() => {
void loadExistingData(customerId); void loadExistingData(studentId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [customerId]); }, [studentId]);
if (showLoading) return <FormLoading />; if (showLoading) return <FormLoading />;
if (showError.show) if (showError.show)
@@ -299,7 +314,7 @@ export function StudentEditForm(): React.JSX.Element {
error={Boolean(errors.email)} error={Boolean(errors.email)}
fullWidth fullWidth
> >
<InputLabel required>Email</InputLabel> <InputLabel required>{t('edit.email-address')}</InputLabel>
<OutlinedInput <OutlinedInput
{...field} {...field}
type="email" type="email"
@@ -321,7 +336,7 @@ export function StudentEditForm(): React.JSX.Element {
error={Boolean(errors.phone)} error={Boolean(errors.phone)}
fullWidth fullWidth
> >
<InputLabel required>Phone</InputLabel> <InputLabel required>{t('edit.phone-number')}</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null} {errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl> </FormControl>
@@ -354,7 +369,7 @@ export function StudentEditForm(): React.JSX.Element {
</Stack> </Stack>
{/* */} {/* */}
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Billing Information</Typography> <Typography variant="h6">{t('edit.billing-information')}</Typography>
<Grid <Grid
container container
spacing={3} spacing={3}
@@ -373,9 +388,12 @@ export function StudentEditForm(): React.JSX.Element {
> >
<InputLabel required>Country</InputLabel> <InputLabel required>Country</InputLabel>
<Select {...field}> <Select {...field}>
<MenuItem value="">No Country selected</MenuItem>
<MenuItem value="US">United States</MenuItem> <MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem> <MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem> <MenuItem value="CA">Canada</MenuItem>
<MenuItem value="DE">Germany</MenuItem>
<MenuItem value="ES">Spain</MenuItem>
</Select> </Select>
{errors.billingAddress?.country ? ( {errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress.country.message}</FormHelperText> <FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
@@ -438,7 +456,7 @@ export function StudentEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.zipCode)} error={Boolean(errors.billingAddress?.zipCode)}
fullWidth fullWidth
> >
<InputLabel required>Zip Code</InputLabel> <InputLabel required>{t('edit.zip-code')}</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? ( {errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText> <FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
@@ -459,7 +477,7 @@ export function StudentEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.line1)} error={Boolean(errors.billingAddress?.line1)}
fullWidth fullWidth
> >
<InputLabel required>Address Line 1</InputLabel> <InputLabel required>{t('edit.address-line-1')}</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.line1 ? ( {errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText> <FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
@@ -494,7 +512,7 @@ export function StudentEditForm(): React.JSX.Element {
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography> <Typography variant="h6">{t('edit.additional-information')}</Typography>
<Grid <Grid
container container
spacing={3} spacing={3}
@@ -541,8 +559,10 @@ export function StudentEditForm(): React.JSX.Element {
> >
<InputLabel required>Language</InputLabel> <InputLabel required>Language</InputLabel>
<Select {...field}> <Select {...field}>
<MenuItem value="">no language selected</MenuItem>
<MenuItem value="en">English</MenuItem> <MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem> <MenuItem value="es">Spanish</MenuItem>
<MenuItem value="de">German</MenuItem>
<MenuItem value="fr">French</MenuItem> <MenuItem value="fr">French</MenuItem>
</Select> </Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null} {errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
@@ -562,8 +582,9 @@ export function StudentEditForm(): React.JSX.Element {
error={Boolean(errors.currency)} error={Boolean(errors.currency)}
fullWidth fullWidth
> >
<InputLabel required>Currency</InputLabel> <InputLabel required>{t('edit.currency')}</InputLabel>
<Select {...field}> <Select {...field}>
<MenuItem value="">no currency selected</MenuItem>
<MenuItem value="USD">USD</MenuItem> <MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem> <MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem> <MenuItem value="GBP">GBP</MenuItem>

View File

@@ -23,21 +23,23 @@ export interface CreateFormProps {
email: string; email: string;
phone?: string; phone?: string;
company?: string; company?: string;
billingAddress?: { // handle seperately
country: string; // billingAddress?: {
state: string; // country: string;
city: string; // state: string;
zipCode: string; // city: string;
line1: string; // zipCode: string;
line2?: string; // line1: string;
}; // line2?: string;
// };
taxId?: string; taxId?: string;
timezone: string; timezone: string;
language: string; language: string;
currency: string; currency: string;
avatar?: string; avatar?: File | null;
// quota?: number; // quota?: number;
// status?: 'pending' | 'active' | 'blocked'; state?: 'pending' | 'active' | 'blocked';
meta: Record<string, null>;
} }
// RULES: form data structure for editing existing student // RULES: form data structure for editing existing student

View File

@@ -1,10 +1,12 @@
'use client'; 'use client';
// src/components/dashboard/teacher/teacher-edit-form.tsx
//
import * as React from 'react'; import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
// //
import { COL_TEACHERS, COL_USER_METAS } from '@/constants'; import { COL_USER_METAS } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab'; import { LoadingButton } from '@mui/lab';
// //
@@ -32,6 +34,7 @@ import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod'; import { z as zod } from 'zod';
import { paths } from '@/paths'; import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
@@ -40,7 +43,6 @@ import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error'; // import ErrorDisplay from '../../error';
import ErrorDisplay from '../error'; import ErrorDisplay from '../error';
import isDevelopment from '@/lib/check-is-development';
// TODO: review this // TODO: review this
const schema = zod.object({ const schema = zod.object({

View File

@@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod'; import { z as zod } from 'zod';
import { paths } from '@/paths'; import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
@@ -40,7 +41,6 @@ import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error'; // import ErrorDisplay from '../../error';
import ErrorDisplay from '../error'; import ErrorDisplay from '../error';
import isDevelopment from '@/lib/check-is-development';
// TODO: review this // TODO: review this
const schema = zod.object({ const schema = zod.object({

View File

@@ -1,8 +1,55 @@
'use client'; 'use client';
import type { BillingAddress } from '@/db/billingAddress/type';
// RULES: sorting direction for teacher lists // RULES: sorting direction for teacher lists
export type SortDir = 'asc' | 'desc'; export type SortDir = 'asc' | 'desc';
// obsoleted
// export interface BillingAddress {
// city: string;
// country: string;
// line1: string;
// line2: string;
// state: string;
// zipCode: string;
// //
// id: string;
// collectionId: string;
// collectionName: string;
// updated: string;
// created: string;
// }
export interface DBUserMeta {
name: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone: string;
quota: number;
company: string;
//
// billingAddress: BillingAddress[] | [];
expand: { billingAddress?: BillingAddress[] };
// status is obsoleted, replace by state
status: 'pending' | 'active' | 'blocked';
state: 'pending' | 'active' | 'blocked';
//
timezone: string;
language: string;
currency: string;
//
id: string;
created: string;
updated?: string;
collectionId: string;
}
// RULES: core teacher data structure // RULES: core teacher data structure
export interface UserMeta { export interface UserMeta {
name: string; name: string;
@@ -14,10 +61,18 @@ export interface UserMeta {
email: string; email: string;
phone?: string; phone?: string;
quota: number; quota: number;
company?: string;
//
billingAddress: BillingAddress | Record<string, never>;
// status is obsoleted, replace by state // status is obsoleted, replace by state
status: 'pending' | 'active' | 'blocked'; status: 'pending' | 'active' | 'blocked';
state: 'pending' | 'active' | 'blocked'; state: 'pending' | 'active' | 'blocked';
//
timezone: string;
language: string;
currency: string;
// //
id: string; id: string;
createdAt: Date; createdAt: Date;

View File

@@ -1,10 +1,15 @@
'use client'; 'use client';
// src/components/dashboard/user_meta/user-meta-edit-form.tsx
//
import * as React from 'react'; import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
// //
import { COL_TEACHERS, COL_USER_METAS } from '@/constants'; import { COL_CUSTOMERS, COL_USER_METAS } from '@/constants';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { getUserMetaById } from '@/db/UserMetas/GetById';
import { UpdateUserMetaById } from '@/db/UserMetas/UpdateById';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab'; import { LoadingButton } from '@mui/lab';
// //
@@ -32,6 +37,7 @@ import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod'; import { z as zod } from 'zod';
import { paths } from '@/paths'; import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
@@ -40,7 +46,6 @@ import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error'; // import ErrorDisplay from '../../error';
import ErrorDisplay from '../error'; import ErrorDisplay from '../error';
import isDevelopment from '@/lib/check-is-development';
// TODO: review this // TODO: review this
const schema = zod.object({ const schema = zod.object({
@@ -89,7 +94,7 @@ export function UserMetaEditForm(): React.JSX.Element {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(['lp_categories']); const { t } = useTranslation(['lp_categories']);
const { id: teacherId } = useParams<{ id: string }>(); const { id: userMetaId } = useParams<{ id: string }>();
// //
const [isUpdating, setIsUpdating] = React.useState<boolean>(false); const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false); const [showLoading, setShowLoading] = React.useState<boolean>(false);
@@ -123,7 +128,7 @@ export function UserMetaEditForm(): React.JSX.Element {
}; };
try { try {
await pb.collection(COL_USER_METAS).update(teacherId, updateData); await pb.collection(COL_USER_METAS).update(userMetaId, updateData);
toast.success('Teacher updated successfully'); toast.success('Teacher updated successfully');
router.push(paths.dashboard.teachers.list); router.push(paths.dashboard.teachers.list);
} catch (error) { } catch (error) {
@@ -133,7 +138,7 @@ export function UserMetaEditForm(): React.JSX.Element {
setIsUpdating(false); setIsUpdating(false);
} }
}, },
[teacherId, router] [userMetaId, router]
); );
const avatarInputRef = React.useRef<HTMLInputElement>(null); const avatarInputRef = React.useRef<HTMLInputElement>(null);
@@ -186,9 +191,9 @@ export function UserMetaEditForm(): React.JSX.Element {
); );
React.useEffect(() => { React.useEffect(() => {
void loadExistingData(teacherId); void loadExistingData(userMetaId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [teacherId]); }, [userMetaId]);
if (showLoading) return <FormLoading />; if (showLoading) return <FormLoading />;
if (showError.show) if (showError.show)

View File

@@ -20,8 +20,8 @@ import { DropdownPopover } from '@/components/core/dropdown/dropdown-popover';
import { DropdownTrigger } from '@/components/core/dropdown/dropdown-trigger'; import { DropdownTrigger } from '@/components/core/dropdown/dropdown-trigger';
import { Logo } from '@/components/core/logo'; import { Logo } from '@/components/core/logo';
import { MobileNav } from './mobile-nav'; import { MobileNav } from '../mobile-nav';
import { PagesPopover } from './pages-popover'; import { PagesPopover } from '../pages-popover';
export function MainNav(): React.JSX.Element { export function MainNav(): React.JSX.Element {
const [openNav, setOpenNav] = React.useState<boolean>(false); const [openNav, setOpenNav] = React.useState<boolean>(false);
@@ -41,15 +41,46 @@ export function MainNav(): React.JSX.Element {
zIndex: 'var(--MainNav-zIndex)', zIndex: 'var(--MainNav-zIndex)',
}} }}
> >
<Container maxWidth="lg" sx={{ display: 'flex', minHeight: 'var(--MainNav-height)', py: '16px' }}> <Container
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto' }}> maxWidth="lg"
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-flex' }}> sx={{ display: 'flex', minHeight: 'var(--MainNav-height)', py: '16px' }}
<Logo color="light" height={32} width={122} /> >
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto' }}
>
<Box
component={RouterLink}
href={paths.home}
sx={{ display: 'inline-flex' }}
>
<Logo
color="light"
height={32}
width={122}
/>
</Box> </Box>
<Box component="nav" sx={{ display: { xs: 'none', md: 'block' } }}> <Box
<Stack component="ul" direction="row" spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}> component="nav"
<NavItem href={paths.components.index} pathname={pathname} title="Components" /> sx={{ display: { xs: 'none', md: 'block' } }}
<NavItem href={paths.docs} pathname={pathname} title="Documentation" /> >
<Stack
component="ul"
direction="row"
spacing={1}
sx={{ listStyle: 'none', m: 0, p: 0 }}
>
<NavItem
href={paths.components.index}
pathname={pathname}
title="Components"
/>
<NavItem
href={paths.docs}
pathname={pathname}
title="Documentation"
/>
</Stack> </Stack>
</Box> </Box>
</Stack> </Stack>
@@ -58,9 +89,20 @@ export function MainNav(): React.JSX.Element {
spacing={2} spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto', justifyContent: 'flex-end' }} sx={{ alignItems: 'center', flex: '1 1 auto', justifyContent: 'flex-end' }}
> >
<Box component="nav" sx={{ display: { xs: 'none', md: 'block' } }}> <Box
<Stack component="ul" direction="row" spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}> component="nav"
<NavItem pathname={pathname} title="Pages"> sx={{ display: { xs: 'none', md: 'block' } }}
>
<Stack
component="ul"
direction="row"
spacing={1}
sx={{ listStyle: 'none', m: 0, p: 0 }}
>
<NavItem
pathname={pathname}
title="Pages"
>
<PagesPopover /> <PagesPopover />
</NavItem> </NavItem>
</Stack> </Stack>
@@ -113,7 +155,10 @@ export function NavItem({
const hasPopover = Boolean(children); const hasPopover = Boolean(children);
const element = ( const element = (
<Box component="li" sx={{ userSelect: 'none' }}> <Box
component="li"
sx={{ userSelect: 'none' }}
>
<Box <Box
{...(hasPopover {...(hasPopover
? { ? {
@@ -157,7 +202,10 @@ export function NavItem({
}} }}
tabIndex={0} tabIndex={0}
> >
<Box component="span" sx={{ flex: '1 1 auto' }}> <Box
component="span"
sx={{ flex: '1 1 auto' }}
>
<Typography <Typography
component="span" component="span"
sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }} sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }}

View File

@@ -5,6 +5,7 @@ const COL_LESSON_TYPES = 'LessonsTypes';
const COL_LESSON_CATEGORIES = 'LessonsCategories'; const COL_LESSON_CATEGORIES = 'LessonsCategories';
const COL_USERS = 'users'; const COL_USERS = 'users';
const COL_USER_METAS = 'UserMetas'; const COL_USER_METAS = 'UserMetas';
const COL_BILLING_ADDRESS = 'billingAddress';
// RULES: // RULES:
// do not use LP_CATEGORIES anymore // do not use LP_CATEGORIES anymore
@@ -56,4 +57,5 @@ export {
COL_VOCABULARIES, COL_VOCABULARIES,
NS_VOCABULARY, NS_VOCABULARY,
// //
COL_BILLING_ADDRESS,
}; };

View File

@@ -1,14 +1,18 @@
// //
// RULES: // RULES:
// api method for get notifications by user id // api method for get notifications by user id
import { pb } from '@/lib/pb';
import { COL_NOTIFICATIONS } from '@/constants'; import { COL_NOTIFICATIONS } from '@/constants';
import { pb } from '@/lib/pb';
import type { Notification } from './type.d'; import type { Notification } from './type.d';
export async function getNotificationsByUserId(userId: string): Promise<Notification[]> { export async function getNotificationsByUserId(userId: string): Promise<Notification[]> {
const records = await pb.collection(COL_NOTIFICATIONS).getFullList({ const records = await pb.collection(COL_NOTIFICATIONS).getFullList({
filter: `author.id = "000000000000001"`, expand: 'author, to_user_id',
filter: `to_user_id.id = "${userId}"`,
sort: '-created', sort: '-created',
}); });
return records as unknown as Notification[]; return records as unknown as Notification[];
} }

View File

@@ -0,0 +1,20 @@
//
// RULES:
// api method for get notifications by user id
import { COL_NOTIFICATIONS } from '@/constants';
import { pb } from '@/lib/pb';
import type { Notification } from './type.d';
export async function getUnreadNotificationsByUserId(userId: string): Promise<Notification[]> {
const records = await pb.collection(COL_NOTIFICATIONS).getFullList({
expand: 'author, to_user_id',
filter: `to_user_id.id = "${userId}" && read = false`,
sort: '-created',
cache: 'no-cache',
requestKey: null,
});
return records as unknown as Notification[];
}

View File

@@ -0,0 +1,11 @@
// api method for update notification record
// RULES:
// TBA
import { pb } from '@/lib/pb';
import { COL_NOTIFICATIONS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import type { NotificationFormProps } from '@/components/dashboard/notification/type.d';
export async function MarkOneAsRead(id: string): Promise<RecordModel> {
return pb.collection(COL_NOTIFICATIONS).update(id, { read: true });
}

View File

@@ -1,17 +1,21 @@
'use client'; 'use client';
import type { User } from '@/types/user';
export type SortDir = 'asc' | 'desc'; export type SortDir = 'asc' | 'desc';
export interface Notification { export interface Notification {
id: string; id: string;
created: string;
createdAt: Date;
read: boolean; read: boolean;
type: string; type: string;
author: Record<string, unknown>; author?: User;
job: Record<string, unknown>; job?: { title: string };
description: string; description?: string;
NOTI_ID: string; company?: { name: string };
created: string; to_user_id?: User;
updated: string; link: string;
} }
export interface CreateFormProps { export interface CreateFormProps {

View File

@@ -1,11 +1,12 @@
// api method for crate student record // api method for crate student record
// RULES: // RULES:
// TBA // TBA
import { pb } from '@/lib/pb'; import { COL_STUDENTS, COL_USER_METAS } from '@/constants';
import { COL_STUDENTS } from '@/constants';
import type { CreateFormProps } from '@/components/dashboard/student/type.d';
import type { RecordModel } from 'pocketbase'; import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { CreateFormProps } from '@/components/dashboard/student/type.d';
export async function createStudent(data: CreateFormProps): Promise<RecordModel> { export async function createStudent(data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_STUDENTS).create(data); return pb.collection(COL_USER_METAS).create(data);
} }

View File

@@ -1,7 +1,28 @@
import { pb } from '@/lib/pb'; import { COL_USER_METAS } from '@/constants';
import { COL_STUDENTS } from '@/constants';
import { RecordModel } from 'pocketbase';
export async function getStudentById(id: string): Promise<RecordModel> { import { pb } from '@/lib/pb';
return pb.collection(COL_STUDENTS).getOne(id); import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type.d';
export async function getStudentById(id: string): Promise<UserMeta> {
const record = await pb.collection(COL_USER_METAS).getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld' });
const temp: UserMeta = {
id: record.id,
name: record.name,
email: record.email,
quota: record.quota,
billingAddress: record.expand.billingAddress ? record.expand.billingAddress[0] : {},
status: record.status,
state: record.state,
createdAt: new Date(record.created),
collectionId: record.collectionId,
avatar: record.avatar,
phone: record.phone,
company: record.company,
timezone: record.timezone,
language: record.language,
currency: record.currency,
};
return temp;
} }

View File

@@ -0,0 +1,10 @@
import { COL_USER_METAS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { UpdateStudent } from './type';
export async function UpdateStudentById(id: string, data: Partial<UpdateStudent>): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).update(id, data);
}

View File

@@ -1,3 +1,5 @@
import type { BillingAddress } from '@/components/dashboard/user_meta/type.d';
// Student type definitions // Student type definitions
export interface Student { export interface Student {
id: string; id: string;
@@ -9,3 +11,29 @@ export interface Student {
status: 'active' | 'blocked' | 'pending'; status: 'active' | 'blocked' | 'pending';
createdAt: Date; createdAt: Date;
} }
export interface UpdateStudent {
name?: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
// avatar_file?: string;
avatar: File | null;
//
email?: string;
phone?: string;
quota?: number;
company?: string;
//
// relation handle seperately
// billingAddress: BillingAddress | Record<string, never>;
// status is obsoleted, replace by state
// status: 'pending' | 'active' | 'blocked';
state?: 'pending' | 'active' | 'blocked';
//
timezone?: string;
language?: string;
currency?: string;
//
taxId?: string;
}

View File

@@ -0,0 +1,10 @@
import { COL_USER_METAS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { UpdateUserMeta } from './type';
export async function UpdateUserMetaById(id: string, data: Partial<UpdateUserMeta>): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).update(id, data);
}

View File

@@ -0,0 +1,39 @@
import type { BillingAddress } from '@/components/dashboard/user_meta/type.d';
// UserMeta type definitions
export interface UserMeta {
id: string;
name: string;
avatar: string;
email: string;
phone: string;
quota: number;
status: 'active' | 'blocked' | 'pending';
createdAt: Date;
}
export interface UpdateUserMeta {
name?: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
// avatar_file?: string;
avatar: File | null;
//
email?: string;
phone?: string;
quota?: number;
company?: string;
//
// relation handle seperately
// billingAddress: BillingAddress | Record<string, never>;
// status is obsoleted, replace by state
// status: 'pending' | 'active' | 'blocked';
state?: 'pending' | 'active' | 'blocked';
//
timezone?: string;
language?: string;
currency?: string;
//
taxId?: string;
}

View File

@@ -0,0 +1,11 @@
// api method for crate student record
// RULES:
// TBA
import { pb } from '@/lib/pb';
import { COL_STUDENTS } from '@/constants';
import type { CreateFormProps } from '@/components/dashboard/student/type.d';
import type { RecordModel } from 'pocketbase';
export async function createStudent(data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_STUDENTS).create(data);
}

View File

@@ -0,0 +1,6 @@
import { pb } from '@/lib/pb';
import { COL_STUDENTS, COL_USER_METAS } from '@/constants';
export async function deleteStudent(id: string): Promise<boolean> {
return pb.collection(COL_USER_METAS).delete(id);
}

View File

@@ -0,0 +1,9 @@
import { COL_STUDENTS, COL_USER_METAS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetActiveCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_USER_METAS).getList(1, 1, {
filter: 'status = "active" && role = "student"',
});
return count;
}

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