Compare commits

...

47 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
133 changed files with 3542 additions and 31575 deletions

2
.gitignore vendored
View File

@@ -7,7 +7,7 @@ _del
*.bak *.bak
*.log *.log
*.del *.del
**/_del **/*del
**/volumes/** **/volumes/**
006_lab 006_lab

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": []
}

View File

@@ -1,11 +1,14 @@
```markdown
# Greetings # Greetings
Hi, Hi,
Imaging you are a software engineer and i will send you the guideline.
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 plesae read it, prepare yourself and i will tell you the task afterwards
please read and understand the markdown files in directory 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`, `/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. it provides background information of project i want you to help.
thanks thanks
```

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

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

@@ -189,7 +189,15 @@ 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 <IconButton

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

@@ -9,18 +9,60 @@ import { Bell as BellIcon } from '@phosphor-icons/react/dist/ssr/Bell';
import { usePopover } from '@/hooks/use-popover'; import { usePopover } from '@/hooks/use-popover';
import { NotificationsPopover } from '../../notifications-popover'; import { NotificationsPopover } from '../../notifications-popover';
import { logger } from '@/lib/default-logger';
// import { NotificationsButton } from './notifications-button'; // 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 { export function NotificationsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>(); 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 ( return (
<React.Fragment> <React.Fragment>
<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={{
variant="dot" '& .MuiBadge-badge': {
height: '20px',
width: '20px',
right: '6px',
top: '6px',
},
}}
badgeContent={listLength}
> >
<IconButton <IconButton
onClick={popover.handleOpen} onClick={popover.handleOpen}
@@ -30,10 +72,14 @@ export function NotificationsButton(): React.JSX.Element {
</IconButton> </IconButton>
</Badge> </Badge>
</Tooltip> </Tooltip>
{/* */}
<NotificationsPopover <NotificationsPopover
anchorEl={popover.anchorRef.current} anchorEl={popover.anchorRef.current}
onClose={popover.handleClose} onClose={popover.handleClose}
open={popover.open} open={popover.open}
onMarkAllAsRead={handleMarkAllAsRead}
onRemoveOne={handleRemoveOne}
setListLength={setListLength}
/> />
</React.Fragment> </React.Fragment>
); );

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,84 +29,11 @@ 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;

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

@@ -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;
}

View File

@@ -0,0 +1,7 @@
import { pb } from '@/lib/pb';
import { COL_STUDENTS } from '@/constants';
import { RecordModel } from 'pocketbase';
export async function getAllStudents(options = {}): Promise<RecordModel[]> {
return pb.collection(COL_STUDENTS).getFullList(options);
}

View File

@@ -0,0 +1,10 @@
import { pb } from '@/lib/pb';
import { COL_USER_METAS } from '@/constants';
export async function getAllStudentsCount(): Promise<number> {
const result = await pb.collection(COL_USER_METAS).getList(1, 1, {
filter: `role = "student"`,
//
});
return result.totalItems;
}

View File

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

View File

@@ -0,0 +1,34 @@
import { COL_BILLING_ADDRESS, COL_STUDENTS, COL_USER_METAS } from '@/constants';
import { RecordModel } from 'pocketbase';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type.d';
export async function getBillingAddressById(id: string): Promise<UserMeta> {
const record = await pb
.collection(COL_BILLING_ADDRESS)
.getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld' });
console.log({ record });
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,9 @@
import { COL_USER_METAS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetPendingCount(): Promise<number> {
const { totalItems: count } = await pb.collection(COL_USER_METAS).getList(1, 1, {
filter: 'status = "pending" && role = "student"',
});
return count;
}

View File

@@ -0,0 +1,3 @@
export function helloCustomer() {
return 'Hello from Customers module!';
}

View File

@@ -0,0 +1,8 @@
import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import type { EditFormProps } from '@/components/dashboard/customer/type.d';
export async function updateCustomer(id: string, data: Partial<EditFormProps>): Promise<RecordModel> {
return pb.collection(COL_CUSTOMERS).update(id, data);
}

View File

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

View File

@@ -0,0 +1,31 @@
# GUIDELINES
This folder contains drivers for `Customer`/`Customers` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx, GetActiveCount.tsx, GetBlockedCount.tsx, GetPendingCount.tsx)
- misc (Helloworld.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
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
- type information defined in `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/Customers/type.d.tsx`
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants';
export async function createCustomer(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
}
```

View File

@@ -0,0 +1,23 @@
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 UpdateBillingAddress {
city?: string;
country?: string;
line1?: string;
line2?: string;
state?: string;
zipCode?: string;
}

View File

@@ -1,8 +1,10 @@
export interface User { export interface User {
id: string; id: string;
name?: string; name?: string;
avatar?: string; avatar: string;
email?: string; email?: string;
collectionId: string;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@@ -40,16 +40,15 @@
"node_modules", "node_modules",
".next", ".next",
// //
"**/*del",
"**/*plan",
"**/*.bak",
"*.bak",
"*.log",
"*.tmp",
"*.bug",
"*.del",
"*.draft",
"**/* copy *.tsx", "**/* copy *.tsx",
"**/* copy.tsx" "**/* copy.tsx",
"**/*.bak",
"**/*.bak",
"**/*.bug",
"**/*.del",
"**/*.draft",
"**/*.log",
"**/*.tmp",
"**/*del",
] ]
} }

14
002_source/docker/.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

View File

@@ -0,0 +1,35 @@
volumes:
shared:
dist:
services:
pocketbase:
# image: ghcr.io/muchobien/pocketbase:latest
build:
context: ./pocketbase/docker
args:
- VERSION=0.26.6 # Specify the PocketBase version here
# hostname: pocketbase
restart: always
# environment:
# ENCRYPTION: example #optional
ports:
- 8090:8090
volumes:
- ./volumes/pocketbase/pb_data:/pb_data
#
- ../pocketbase/pb_migrations:/pb_migrations
- ../pocketbase/pb_hooks:/pb_hooks
# healthcheck:
# #optional (recommended) since v0.10.0
# test: wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
# interval: 5s
# timeout: 5s
# retries: 5
deploy:
resources:
limits:
cpus: 0.5
reservations:
cpus: 0.01

View File

@@ -6,7 +6,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: "1" cpus: '1'
doc: doc:
environment: environment:
@@ -14,7 +14,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: "1" cpus: '1'
ionic_mobile: ionic_mobile:
environment: environment:
@@ -22,7 +22,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: "1" cpus: '1'
api_ts: api_ts:
environment: environment:
@@ -30,4 +30,4 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: "1" cpus: '1'

View File

@@ -26,9 +26,9 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: "0.5" cpus: '0.5'
reservations: reservations:
cpus: "0.01" cpus: '0.01'
doc: doc:
build: ./doc build: ./doc
@@ -50,9 +50,9 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: "0.5" cpus: '0.5'
reservations: reservations:
cpus: "0.01" cpus: '0.01'
ionic_mobile: ionic_mobile:
# image: node:20-bullseye-slim # image: node:20-bullseye-slim
@@ -80,15 +80,16 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: "0.5" cpus: '0.5'
reservations: reservations:
cpus: "0.01" cpus: '0.01'
api_ts: api_ts:
# TODO: review this config, the api_ts volumes should be place inside 002_source/docker/volumes
image: 192.168.10.61:5000/api_ts_ubuntu image: 192.168.10.61:5000/api_ts_ubuntu
# build: ./api_ts # build: ./api_ts
volumes: volumes:
- ./api_ts:/app - ../api_ts:/app
working_dir: /app working_dir: /app
# env_file: # env_file:
# - .env # - .env
@@ -117,33 +118,3 @@ services:
cpus: 0.5 cpus: 0.5
reservations: reservations:
cpus: 0.01 cpus: 0.01
pocketbase:
# image: ghcr.io/muchobien/pocketbase:latest
build:
context: ./pocketbase/docker
args:
- VERSION=0.26.6 # Specify the PocketBase version here
# hostname: pocketbase
restart: always
# environment:
# ENCRYPTION: example #optional
ports:
- 8090:8090
volumes:
- ./pocketbase/volumes/pb_data:/pb_data
- ./pocketbase/pb_hooks:/pb_hooks
- ./pocketbase/pb_migrations:/pb_migrations
# healthcheck:
# #optional (recommended) since v0.10.0
# test: wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
# interval: 5s
# timeout: 5s
# retries: 5
deploy:
resources:
limits:
cpus: 0.5
reservations:
cpus: 0.01

View File

@@ -1,3 +1,4 @@
.env
**/*.log **/*.log
**/*.del **/*.del
**/*.bak **/*.bak

View File

@@ -1,9 +1,10 @@
{ {
"endOfLine": "lf",
"printWidth": 120, "printWidth": 120,
"quoteProps": "consistent", "quoteProps": "consistent",
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,
"trailingComma": "all", "trailingComma": "es5",
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-unused-imports-configurable"] "plugins": []
} }

View File

@@ -0,0 +1,3 @@
# TODO
- [ ] add login mechanism

View File

@@ -1,8 +1,8 @@
import { defineConfig } from "cypress"; import { defineConfig } from 'cypress';
export default defineConfig({ export default defineConfig({
e2e: { e2e: {
baseUrl: "http://localhost:5173", baseUrl: 'http://localhost:5173',
setupNodeEvents(on, config) { setupNodeEvents(on, config) {
// implement node event listeners here // implement node event listeners here
}, },

View File

@@ -0,0 +1,17 @@
{
"folders": [
{
"path": ".",
},
{
"path": "./../../001_documentation",
},
{
"path": "../../000_AI_WORKSPACE",
},
],
"settings": {
"git.ignoreLimitWarning": true,
"remote.autoForwardPortsFallback": 0,
},
}

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />

View File

@@ -1,5 +1,7 @@
{ {
"watch": ["src"], "watch": [
"src"
],
"ext": "ts,tsx", "ext": "ts,tsx",
"exec": "npm run bulid" "exec": "npm run bulid"
} }

View File

@@ -21,11 +21,14 @@
"@ionic/react-router": "^8.0.0", "@ionic/react-router": "^8.0.0",
"@ionic/storage": "^4.0.0", "@ionic/storage": "^4.0.0",
"@lifeomic/attempt": "^3.1.0", "@lifeomic/attempt": "^3.1.0",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6",
"@types/react-router": "^5.1.20", "@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"axios": "^1.8.1", "axios": "^1.8.1",
"i18next": "^24.2.2", "i18next": "^24.2.2",
"ionicons": "^7.0.0", "ionicons": "^7.0.0",
"pocketbase": "^0.26.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
@@ -55,6 +58,7 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-unused-imports-configurable": "^1.14.2", "prettier-plugin-unused-imports-configurable": "^1.14.2",
"sass": "^1.88.0",
"terser": "^5.4.0", "terser": "^5.4.0",
"typescript": "^5.1.6", "typescript": "^5.1.6",
"vite": "~5.2.0", "vite": "~5.2.0",
@@ -2777,6 +2781,338 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/@parcel/watcher/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/@prettier/plugin-xml": { "node_modules/@prettier/plugin-xml": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-2.2.0.tgz",
@@ -2836,6 +3172,59 @@
"npm": ">=7.10.0" "npm": ">=7.10.0"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.74.7",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.7.tgz",
"integrity": "sha512-X3StkN/Y6KGHndTjJf8H8th7AX4bKfbRpiVhVqevf0QWlxl6DhyJ0TYG3R0LARa/+xqDwzU9mA4pbJxzPCI29A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.74.7",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.74.7.tgz",
"integrity": "sha512-nSNlfuGdnHf4yB0S+BoNYOE1o3oAH093weAYZolIHfS2stulyA/gWfSk/9H4ZFk5mAAHb5vNqAeJOmbdcGPEQw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.74.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.7.tgz",
"integrity": "sha512-u4o/RIWnnrq26orGZu2NDPwmVof1vtAiiV6KYUXd49GuK+8HX+gyxoAYqIaZogvCE1cqOuZAhQKcrKGYGkrLxg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.74.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.74.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.74.7.tgz",
"integrity": "sha512-j60esTQF+ES0x52kQUYOX0Z8AJUcqCGANj6GaOf8J3YQz2bZPB1imLSw4SFeM3Ozv8uO/X/Dmh3IT1z+y57ZLQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.74.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.74.7",
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -4781,6 +5170,22 @@
"regexp-to-ast": "0.5.0" "regexp-to-ast": "0.5.0"
} }
}, },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
@@ -8843,6 +9248,13 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/immutable": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -12281,6 +12693,12 @@
"node": ">=10.4.0" "node": ">=10.4.0"
} }
}, },
"node_modules/pocketbase": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.0.tgz",
"integrity": "sha512-WBBeOgz4Jnrd7a1KEzSBUJqpTortKKCcp16j5KoF+4tNIyQHsmynj+qRSvS56/RVacVMbAqO8Qkfj3N84fpzEw==",
"license": "MIT"
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -13023,6 +13441,20 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -13824,6 +14256,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": {
"version": "1.88.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sax": { "node_modules/sax": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz",

View File

@@ -36,11 +36,14 @@
"@ionic/react-router": "^8.0.0", "@ionic/react-router": "^8.0.0",
"@ionic/storage": "^4.0.0", "@ionic/storage": "^4.0.0",
"@lifeomic/attempt": "^3.1.0", "@lifeomic/attempt": "^3.1.0",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6",
"@types/react-router": "^5.1.20", "@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"axios": "^1.8.1", "axios": "^1.8.1",
"i18next": "^24.2.2", "i18next": "^24.2.2",
"ionicons": "^7.0.0", "ionicons": "^7.0.0",
"pocketbase": "^0.26.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
@@ -70,6 +73,7 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-unused-imports-configurable": "^1.14.2", "prettier-plugin-unused-imports-configurable": "^1.14.2",
"sass": "^1.88.0",
"terser": "^5.4.0", "terser": "^5.4.0",
"typescript": "^5.1.6", "typescript": "^5.1.6",
"vite": "~5.2.0", "vite": "~5.2.0",
@@ -79,4 +83,4 @@
"engines": { "engines": {
"node": "==18" "node": "==18"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -ex
npm run dev

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -ex
rsync -avh --exclude="node_modules" ../ionic_mobile/ \
../_archive/ionic_mobile_draft_012
echo "done"

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -ex
git add src
git commit -m'update,'
echo "done"

View File

@@ -21,6 +21,10 @@ import ConnectivesWordPage from './pages/Favorite/ConnectivesWordPage';
import FavVocabularyPage from './pages/Favorite/Vocabulary'; import FavVocabularyPage from './pages/Favorite/Vocabulary';
import FavoriteVocabularyPage from './pages/Favorite/WordPage'; import FavoriteVocabularyPage from './pages/Favorite/WordPage';
import ConnectivesPage from './pages/Lesson/ConnectivesPage'; import ConnectivesPage from './pages/Lesson/ConnectivesPage';
import AuthHome from './pages/auth/Home';
import { AuthLogin } from './pages/auth/Login';
import { AuthSignUp } from './pages/auth/SignUp';
import Lesson from './pages/Lesson/index'; import Lesson from './pages/Lesson/index';
// NOTES: old version using json file // NOTES: old version using json file
@@ -88,6 +92,7 @@ function RouteConfig() {
<ListeningPractice /> <ListeningPractice />
</Route> </Route>
{/* http://localhost:5173/connective_revision/r/000000000000001 */}
<Route exact path={`${CONNECTIVE_REVISION_LINK}/r/:p_route`}> <Route exact path={`${CONNECTIVE_REVISION_LINK}/r/:p_route`}>
<ConnectiveRevisionQuizRun /> <ConnectiveRevisionQuizRun />
</Route> </Route>
@@ -96,6 +101,7 @@ function RouteConfig() {
<ConnectiveRevisionQuizResult /> <ConnectiveRevisionQuizResult />
</Route> </Route>
{/* http://localhost:5173/connective_revision */}
<Route exact path={CONNECTIVE_REVISION_LINK}> <Route exact path={CONNECTIVE_REVISION_LINK}>
<ConnectiveRevisionSelectCategory /> <ConnectiveRevisionSelectCategory />
</Route> </Route>
@@ -154,6 +160,18 @@ function RouteConfig() {
<ConnectivesPage /> <ConnectivesPage />
</Route> </Route>
<Route exact path={`/auth/Home`}>
<AuthHome />
</Route>
<Route exact path={`/auth/login`}>
<AuthLogin />
</Route>
<Route exact path={`/auth/signup`}>
<AuthSignUp />
</Route>
{/* TODO: remove below */} {/* TODO: remove below */}
<Route exact path="/tab1"> <Route exact path="/tab1">
<Tab1 /> <Tab1 />

View File

@@ -0,0 +1,21 @@
import { IonCol, IonRouterLink, IonRow } from '@ionic/react';
function Action(props: { message: string; text: string; link: string }): React.JSX.Element {
return (
<>
<IonRow className="ion-text-center ion-justify-content-center">
<IonCol size="12">
<p>
{props.message}
<IonRouterLink className="custom-link" routerLink={props.link}>
{' '}
{props.text} &rarr;
</IonRouterLink>
</p>
</IonCol>
</IonRow>
</>
);
}
export { Action };

View File

@@ -0,0 +1,40 @@
import { IonInput, IonLabel } from '@ionic/react';
import styles from './style.module.scss';
function CustomField({
field,
errors,
}: {
field: {
id: string;
label: string;
required: boolean;
input: {
props: { type: string; placeholder: string };
state: {
value: string;
reset: (newValue: any) => void;
onIonChange: (e: any) => Promise<void>;
onKeyUp: (e: any) => Promise<void>;
};
};
};
errors: any;
}): React.JSX.Element {
const error = errors && errors.filter((e) => e.id === field.id)[0];
const errorMessage = error && errors.filter((e) => e.id === field.id)[0].message;
return (
<>
<div className={styles.field}>
<IonLabel className={styles.fieldLabel}>
{field.label}
{error && <p className="animate__animated animate__bounceIn">{errorMessage}</p>}
</IonLabel>
<IonInput className={styles.customInput} {...field.input.props} {...field.input.state} />
</div>
</>
);
}
export { CustomField };

View File

@@ -0,0 +1,29 @@
.field:not(:last-child) {
margin-bottom: 1rem !important;
}
.field {
ion-label {
padding-left: 0.2rem;
padding-right: 0.5rem;
color: #d3a6c7;
display: flex;
justify-content: space-between;
align-content: center;
align-items: center;
p {
color: rgb(236, 149, 35);
}
}
}
.customInput {
// --background: #834e76;
--padding-bottom: 1rem;
--padding-top: 1rem;
--padding-start: 1rem;
--padding-end: 1rem;
border-radius: 10px;
margin-top: 0.25rem;
transition: all 0.2s linear;
}

View File

@@ -0,0 +1,5 @@
function Helloworld(): React.JSX.Element {
return <>Helloworld Component</>;
}
export { Helloworld };

View File

@@ -0,0 +1,9 @@
export const Wave = () => (
<svg style={{ marginBottom: '-0.5rem' }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 320">
<path
fill="#7a506f"
fillOpacity="1"
d="M0,288L40,277.3C80,267,160,245,240,224C320,203,400,181,480,176C560,171,640,181,720,181.3C800,181,880,171,960,144C1040,117,1120,75,1200,58.7C1280,43,1360,53,1400,58.7L1440,64L1440,320L1400,320C1360,320,1280,320,1200,320C1120,320,1040,320,960,320C880,320,800,320,720,320C640,320,560,320,480,320C400,320,320,320,240,320C160,320,80,320,40,320L0,320Z"
></path>
</svg>
);

View File

@@ -0,0 +1,71 @@
import { useFormInput } from './utils';
export const useSignupFields = () => {
return [
{
id: 'name',
label: 'Name',
required: true,
input: {
props: {
type: 'text',
placeholder: 'Joe Bloggs',
},
state: useFormInput(''),
},
},
{
id: 'email',
label: 'Email',
required: true,
input: {
props: {
type: 'email',
placeholder: 'joe@bloggs.com',
},
state: useFormInput(''),
},
},
{
id: 'password',
label: 'Password',
required: true,
input: {
props: {
type: 'password',
placeholder: '*********',
},
state: useFormInput(''),
},
},
];
};
export const useLoginFields = () => {
return [
{
id: 'email',
label: 'Email',
required: true,
input: {
props: {
type: 'email',
placeholder: 'joe@bloggs.com',
},
state: useFormInput(''),
},
},
{
id: 'password',
label: 'Password',
required: true,
input: {
props: {
type: 'password',
placeholder: '*******',
},
state: useFormInput(''),
},
},
];
};

View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
export const useFormInput = (initialValue = '') => {
const [value, setValue] = useState(initialValue);
const handleChange = async (e) => {
const tempValue = await e.currentTarget.value;
setValue(tempValue);
};
return {
value,
reset: (newValue) => setValue(newValue),
onIonChange: handleChange,
onKeyUp: handleChange,
};
};
export const validateForm = (fields) => {
let errors = [];
fields.forEach((field) => {
if (field.required) {
const fieldValue = field.input.state.value;
if (fieldValue === '') {
const error = {
id: field.id,
message: `Please check your ${field.id}`,
};
errors.push(error);
}
}
});
return errors;
};

View File

@@ -2,5 +2,8 @@
- single file contains single function only - single file contains single function only
- please refer to the `tsx` files already exist in this directory for - please refer to the `tsx` files already exist in this directory for
- styling - styling
- naming convention - naming convention
- `useListQuizListeningPracticeContent.tsx` is a sample for listing question in listening practice.

View File

@@ -0,0 +1,30 @@
import { idCard } from 'ionicons/icons';
import { QuizCRQuestion } from '../types/QuizCRQuestion';
import { usePocketBase } from './usePocketBase';
import { QueryClient } from '@tanstack/react-query';
import PocketBase from 'pocketbase';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
});
const fetchCRQuestions = async (cat_id: string, pb: PocketBase) => {
const response = await queryClient.fetchQuery({
queryKey: ['fetchData', cat_id],
staleTime: 60 * 1000,
queryFn: async () => {
return await pb.collection('QuizCRQuestions').getList<QuizCRQuestion>(1, 9999, {
filter: `cat_id = "${cat_id}"`,
$autoCancel: false,
});
},
});
return response;
};
export default fetchCRQuestions;

View File

@@ -0,0 +1,38 @@
// CR = ConnectiveRevision
import { usePocketBase } from './usePocketBase.tsx';
import { useQuery } from '@tanstack/react-query';
import IListeningPracticeCategory from '../interfaces/IListeningPracticeCategory.tsx';
const useListQuizCRCategories = () => {
const { user, pb } = usePocketBase();
return useQuery({
queryKey: [
'useListQuizConnectiveRevisionContent',
'feeds',
'all',
user?.id || '',
//
],
staleTime: 60 * 1000,
queryFn: async ({
queryKey,
}: {
queryKey: [
'useListQuizConnectiveRevisionContent',
'feeds',
'all',
string | null,
//
];
}) => {
console.log('calling useListQuizConnectiveRevisionContent');
return await pb.collection('LessonsCategories').getList<IListeningPracticeCategory>(1, 9999, {
sort: 'pos',
$autoCancel: false,
});
},
// enabled: !!user?.id,
});
};
export default useListQuizCRCategories;

View File

@@ -0,0 +1,26 @@
import { usePocketBase } from './usePocketBase.tsx';
import { useQuery } from '@tanstack/react-query';
import { QuizCRQuestion } from '../types/QuizCRQuestion.ts/index.ts';
const useListQuizCRQuestionByCRCategoryId = (CRCategoryId: string) => {
const { user, pb } = usePocketBase();
return useQuery({
queryKey: ['useListQuizCRQuestionByCRCategoryId', 'feeds', 'all', user?.id || '', CRCategoryId],
staleTime: 60 * 1000,
queryFn: async ({
queryKey,
}: {
queryKey: ['useListQuizCRQuestionByCRCategoryId', 'feeds', 'all', string | null, string];
}) => {
console.log('calling useListQuizCRQuestionByCRCategoryId');
return await pb.collection('QuizCRQuestions').getList<QuizCRQuestion>(1, 9999, {
filter: `cat_id = "${CRCategoryId}"`,
sort: 'id',
$autoCancel: false,
});
},
// enabled: !!user?.id && !!CRCategoryId,
});
};
export default useListQuizCRQuestionByCRCategoryId;

View File

@@ -5,7 +5,9 @@ interface IConnectivesRevisionCategory {
test_i: number; test_i: number;
cat_info: string; cat_info: string;
cat_name: string; cat_name: string;
content: IConnectivesRevisionQuestion[] | []; content?: IConnectivesRevisionQuestion[] | [];
//
id: string;
} }
export default IConnectivesRevisionCategory; export default IConnectivesRevisionCategory;

View File

@@ -1,3 +1,4 @@
// abonded
interface IQuestionMeta { interface IQuestionMeta {
question_idx: number; question_idx: number;
question_fh: string; question_fh: string;

View File

@@ -155,7 +155,7 @@ const QuizContent: React.FC<IQuestionCard> = ({
> >
{answer_list.map((connective, idx) => { {answer_list.map((connective, idx) => {
return ( return (
<div> <div key={idx}>
<IonButton <IonButton
color={'dark'} color={'dark'}
ref={button_refs[idx]} ref={button_refs[idx]}

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