Compare commits

..

33 Commits

Author SHA1 Message Date
louiscklaw
f44539bf63 ``refactor Update .gitignore to exclude files with 'old' suffix in any directory`` 2025-05-15 11:35:47 +08:00
louiscklaw
407f622f24 ``refactor Prettier config to add comment placeholder for potential future plugin additions `` 2025-05-15 11:35:40 +08:00
louiscklaw
7e2844dd74 ```
refactor Student and Teacher create/edit forms to implement i18n support, update UI components, and standardize API calls
```
2025-05-15 11:35:29 +08:00
louiscklaw
097918340c ```
refactor Teachers list page to remove outdated implementation, simplifying component structure and improving maintainability
```
2025-05-15 11:31:03 +08:00
louiscklaw
3620837a6a ``refactor UserMeta creation and authentication client to improve documentation and type consistency`` 2025-05-15 11:27:02 +08:00
louiscklaw
c83e8c1b6e ```
refactor Student and UserMeta type definitions to deprecate obsolete fields, standardize structure, and update billing address format
```
2025-05-15 11:13:15 +08:00
louiscklaw
af15a6bce0 ```
refactor Student type definitions to deprecate obsolete fields, standardize structure, and update billing address format
```
2025-05-15 11:12:56 +08:00
louiscklaw
d0215cf23f ```
refactor GetById APIs for Students, Teachers, and UserMetas to use consistent type definitions and expand parameters
```
2025-05-15 11:12:29 +08:00
louiscklaw
e523f80123 ```
refactor UserMeta type definitions to deprecate obsolete fields and standardize structure
```
2025-05-15 11:10:40 +08:00
louiscklaw
7f8f8824a7 ```
refactor getImageUrlFromFile to use Pocketbase records and correct path in teachers routes
```
2025-05-15 11:10:12 +08:00
louiscklaw
2aa96eec62 ```
refactor student and teacher APIs to use COL_USER_METAS collection, standardize role assignment, and update type definitions
```
2025-05-15 09:27:38 +08:00
louiscklaw
ecab41abbf `` fix GetUserById API to include requestKey option for Pocketbase compatibility `` 2025-05-15 09:27:20 +08:00
louiscklaw
5a1832ca89 ```
remove obsolete GetAllCount API and related guidelines from Users.old module
```
2025-05-15 09:27:08 +08:00
louiscklaw
160c93b83d `` refactor GetUserById function and add Create/UpdateUser APIs with type definitions `` 2025-05-15 09:26:36 +08:00
louiscklaw
d4fcc1dd8f ```
add UserActivationEditForm component for user activation management
```
2025-05-15 09:24:41 +08:00
louiscklaw
8e3d463f78 ```
use dynamic route parameter for user ID in UserActivationEditForm
```
2025-05-15 09:24:27 +08:00
louiscklaw
fbf79b040f `` remove placeholder _PROMPT.md file and reference to external draft for UserMeta editing page `` 2025-05-15 09:24:21 +08:00
louiscklaw
e34782844e ```
add user profile navigation logic and update button in SettingContainer
```
2025-05-15 09:19:59 +08:00
louiscklaw
ba8e9cca69 fix translation, 2025-05-15 08:35:33 +08:00
louiscklaw
cc9fe057c1 ```
add abbreviations section and clarify guideline reading requirement
```
2025-05-14 18:45:23 +08:00
louiscklaw
cdd95faa89 ```
add new student menu route and component, update login default values and redirect logic
```
2025-05-14 18:31:04 +08:00
louiscklaw
030fc1a808 update login requirement for mobile, 2025-05-14 18:13:15 +08:00
louiscklaw
05c69481b5 update check session working, 2025-05-14 18:10:22 +08:00
louiscklaw
0fcc194860 in the middle, working for login and logout test, 2025-05-14 17:19:48 +08:00
louiscklaw
56f0f30ffb ```
replace inline loading text with LoadingScreen component in multiple pages
```
2025-05-14 16:27:30 +08:00
louiscklaw
0aefbfaeae fix typo, 2025-05-14 16:25:57 +08:00
louiscklaw
628c72190b fix typo, 2025-05-14 16:18:39 +08:00
louiscklaw
886a314df7 update settings pages in the middle, 2025-05-14 16:18:04 +08:00
louiscklaw
efc2d31f7c ```
add new student info route and related components, update auth guard implementation and signup success redirect
```
2025-05-14 15:40:59 +08:00
louiscklaw
1938e95948 ```
use async/await for authClient.signInWithPassword to ensure proper execution order
```
2025-05-14 15:20:02 +08:00
louiscklaw
8d37fba393 update login flow, in the middle, 2025-05-14 15:17:04 +08:00
louiscklaw
af160edd42 ```
update .gitignore to add api_ts and dist directories to exclusion list
```
2025-05-14 15:16:21 +08:00
louiscklaw
d880420a28 ``add configuration files for CrossNote, VSCode extensions, and documentation for system architecture and design requirements`` 2025-05-14 15:15:45 +08:00
98 changed files with 2437 additions and 632 deletions

15
.crossnote/config.js Normal file
View File

@@ -0,0 +1,15 @@
({
katexConfig: {
"macros": {}
},
mathjaxConfig: {
"tex": {},
"options": {},
"loader": {}
},
mermaidConfig: {
"startOnLoad": false
},
})

19
.crossnote/head.html Normal file
View File

@@ -0,0 +1,19 @@
<!-- The content below will be included at the end of the <head> element. -->
<script type="text/javascript">
const configureMermaidIconPacks = () => {
window['mermaid'].registerIconPacks([
{
name: 'logos',
loader: () => fetch('https://unpkg.com/@iconify-json/logos/icons.json').then((res) => res.json()),
},
]);
};
if (document.readyState !== 'loading') {
configureMermaidIconPacks();
} else {
document.addEventListener('DOMContentLoaded', () => {
configureMermaidIconPacks();
});
}
</script>

12
.crossnote/parser.js Normal file
View File

@@ -0,0 +1,12 @@
({
// Please visit the URL below for more information:
// https://shd101wyy.github.io/markdown-preview-enhanced/#/extend-parser
onWillParseMarkdown: async function(markdown) {
return markdown;
},
onDidParseMarkdown: async function(html) {
return html;
},
})

8
.crossnote/style.less Normal file
View File

@@ -0,0 +1,8 @@
/* Please visit the URL below for more information: */
/* https://shd101wyy.github.io/markdown-preview-enhanced/#/customize-css */
.markdown-preview.markdown-preview {
// modify your style here
// eg: background-color: blue;
}

4
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# api_ts
dist
.next
node_modules
005_references/
@@ -8,6 +11,7 @@ _del
*.log
*.del
**/*del
**/*old
**/volumes/**
006_lab

View File

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

View File

@@ -15,7 +15,9 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"workbench.iconTheme": "material-icon-theme",
"workbench.colorTheme": "Default Dark Modern",
"workbench.colorTheme": "Mermaid Dark",
"editor.formatOnSave": true,
"git.ignoreLimitWarning": true
"git.ignoreLimitWarning": true,
//
"markdown.styles": ["https://use.fontawesome.com/releases/v5.7.1/css/all.css"]
}

View File

@@ -28,5 +28,10 @@
- `006_lab` my test (POC) of this project
- `README.md` Readme of this project
- `TODO.md` todo list of this project
- `001_documentation/Requirements/REQ0019/index.md` describes updated system architecture
- if the directory contains `_GUIDELINES.md`, please read it before operation
## Abbreviations
T.B.A.

View File

@@ -2,7 +2,7 @@
tags: cms, login-flow
---
# login flow
# CMS login flow
## description

View File

@@ -0,0 +1,43 @@
---
tags: architecture,mobile, cms, db
---
# System architecture
## Description
it should have a family photo of used framework
## Diagram
```mermaid {align="center"}
architecture-beta
group running_config(logos:aws-opsworks)[running_config]
service db(database)[pocketbase] in running_config
service tra1(internet)[incoming traffic 3000] in running_config
service cms(logos:nextjs)[next app] in running_config
service tra2(internet)[incoming traffic 5173] in running_config
service ionic(logos:ionic)[ionic app] in running_config
tra1:R --> L:cms
cms:R --> L:db
tra2:R --> L:ionic
ionic:R --> B:db
%% group planning(logos:aws-lambda)[planning]
%% service api_ts(logos:aws-lambda)[api_ts] in planning
%% service pg_db(logos:postgresql)[pg_db] in planning
%% ui:R --> L:api_ts
%% ionic:R --> L:api_ts
%% api_ts:B --> T:pg_db
%% service task_server(logos:aws-lambda)[task_server] in planning
%% api_ts:R --> L:task_server
%% service marketing(logos:wordpress-icon)[marketing] in planning
```

View File

@@ -0,0 +1,37 @@
---
tags: mobile, login-flow
---
# Mobile login flow
## description
```mermaid
graph TD;
Start-->A;
A-->B;
B-->C;
B-->D;
D-->E;
E-->F;
C-->G;
G-->A
F-->End;
A[greeting, asking username and password]
B[check if username and password is valid]
C[pasword failed]
D[pasword ok]
E[login success]
F[redirect to '/dashboard']
G[prompt user wrong username and password]
Start((start));
End((end))
```
### relations
[REQ0016](../REQ0016/index.md)

View File

@@ -17,5 +17,8 @@
- [REQ0013: cms dashboard](./REQ0013/index.md)
- [REQ0014: mobile client](./REQ0014/index.md)
- [REQ0015: pocketbase json schema to dbml converter](./REQ0015/index.md)
- [REQ0016: login flow](./REQ0016/index.md)
- [REQ0016: CMS login flow](./REQ0016/index.md)
- [REQ0017: lesson page documentation](./REQ0017/index.md)
- [REQ0018: family photo of frameworks](./REQ0018/index.md)
- [REQ0019: System architecture](./REQ0019/index.md)
- [REQ0020: Mobile login flow](./REQ0020/index.md)

View File

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

View File

@@ -1,19 +1,25 @@
'use client';
// src/app/dashboard/students/create/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import type { Metadata } from 'next';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import { useTranslation } from 'react-i18next';
import { config } from '@/config';
import { paths } from '@/paths';
import { StudentCreateForm } from '@/components/dashboard/student/student-create-form';
export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['students']);
return (
<Box
sx={{
@@ -29,16 +35,19 @@ export default function Page(): React.JSX.Element {
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.customers.list}
href={paths.dashboard.students.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Customers
{t('students')}
</Link>
</div>
<div>
<Typography variant="h4">Create customer</Typography>
<Typography variant="h4">
{t('create-student')}
{/* */}
</Typography>
</div>
</Stack>
<StudentCreateForm />

View File

@@ -1,6 +1,9 @@
'use client';
// src/app/dashboard/students/edit/[customerId]/page.tsx
// src/app/dashboard/students/edit/[id]/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';

View File

@@ -1,4 +1,9 @@
'use client';
// src/app/dashboard/teachers/create/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';
@@ -6,12 +11,15 @@ import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import { useTranslation } from 'react-i18next';
import { config } from '@/config';
import { paths } from '@/paths';
import { TeacherCreateForm } from '@/components/dashboard/teacher/teacher-create-form';
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['teachers']);
return (
<Box
sx={{
@@ -32,11 +40,14 @@ export default function Page(): React.JSX.Element {
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Teachers
{t('teachers')}
</Link>
</div>
<div>
<Typography variant="h4">Create teacher</Typography>
<Typography variant="h4">
{t('create-teacher')}
{/* */}
</Typography>
</div>
</Stack>
<TeacherCreateForm />

View File

@@ -1,5 +1,9 @@
'use client';
// src/app/dashboard/teachers/edit/[id]/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';
@@ -10,7 +14,8 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { useTranslation } from 'react-i18next';
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 { TeacherEditForm } from '@/components/dashboard/teacher/teacher-edit-form';
export default function Page(): React.JSX.Element {

View File

@@ -1,216 +0,0 @@
// src/app/dashboard/teachers/list/page.tsx
'use client';
// RULES:
// contains list page for teachers (Teachers)
//
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { COL_USER_METAS } from '@/constants';
import { LoadingButton } from '@mui/lab';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import type { ListResult, RecordModel } from 'pocketbase';
import { TeachersFilters } from '@/components/dashboard/teacher/teachers-filters';
import { TeachersPagination } from '@/components/dashboard/teacher/teachers-pagination';
import { TeachersSelectionProvider } from '@/components/dashboard/teacher/teachers-selection-context';
import { TeachersTable } from '@/components/dashboard/teacher/teachers-table';
import type { Teacher } from '@/components/dashboard/teacher/type.d';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import ErrorDisplay from '@/components/dashboard/error';
import { defaultTeacher } from '@/components/dashboard/teacher/_constants';
import FormLoading from '@/components/loading';
export default function Page({ searchParams }: PageProps): React.JSX.Element {
const { t } = useTranslation(['teachers']);
const router = useRouter();
const { email, phone, sortDir, status } = searchParams;
const [teacherData, setTeacherData] = React.useState<Teacher[]>([]);
const [isLoadingAddPage, setIsLoadingAddPage] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [rowsPerPage, setRowsPerPage] = React.useState<number>(5);
//
const [f, setF] = React.useState<Teacher[]>([]);
const [currentPage, setCurrentPage] = React.useState<number>(0);
//
const [recordCount, setRecordCount] = React.useState<number>(0);
const [listOption, setListOption] = React.useState({ filter: '' });
const [listSort, setListSort] = React.useState({});
function isListOptionChanged() {
return JSON.stringify(listOption) === '{}';
}
//
const reloadRows = async (): Promise<void> => {
try {
const listOptionTeacherOnly = isListOptionChanged()
? { filter: `role = "teacher"` }
: { filter: [listOption.filter, `role = "teacher"`].join(' && ') };
const models: ListResult<RecordModel> = await pb
.collection(COL_USER_METAS)
.getList(currentPage + 1, rowsPerPage, listOptionTeacherOnly);
const { items, totalItems } = models;
const tempTeacher: Teacher[] = items.map((lt) => {
return { ...defaultTeacher, ...lt };
});
setTeacherData(tempTeacher);
setRecordCount(totalItems);
setF(tempTeacher);
} catch (error) {
logger.error(error);
setShowError({
//
show: true,
detail: JSON.stringify(error, null, 2),
});
} finally {
setShowLoading(false);
}
};
const [lastListOption, setLastListOption] = React.useState({});
const isFirstRun = React.useRef(false);
React.useEffect(() => {
if (!isFirstRun.current) {
isFirstRun.current = true;
} else if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) {
// reset page number as tab changes
setLastListOption(listOption);
setCurrentPage(0);
void reloadRows();
} else {
void reloadRows();
}
}, [currentPage, rowsPerPage, listOption]);
React.useEffect(() => {
const tempFilter = [];
let tempSortDir = '';
if (status) {
tempFilter.push(`status = "${status}"`);
}
if (sortDir) {
tempSortDir = `-created`;
}
if (email) {
tempFilter.push(`email ~ "%${email}%"`);
}
if (phone) {
tempFilter.push(`phone ~ "%${phone}%"`);
}
let preFinalListOption = { filter: '' };
if (tempFilter.length > 0) {
preFinalListOption = { filter: tempFilter.join(' && ') };
}
if (tempSortDir.length > 0) {
preFinalListOption = { ...preFinalListOption, sort: tempSortDir };
}
setListOption(preFinalListOption);
}, [sortDir, email, phone, status]);
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code={-1}
details={showError.detail}
/>
);
return (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h4">{t('list.title')}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<LoadingButton
loading={isLoadingAddPage}
onClick={(): void => {
setIsLoadingAddPage(true);
router.push(paths.dashboard.teachers.create);
}}
startIcon={<PlusIcon />}
variant="contained"
>
{t('list.add')}
</LoadingButton>
</Box>
</Stack>
<TeachersSelectionProvider teachers={f}>
<Card>
<TeachersFilters
filters={{ email, phone, status }}
fullData={teacherData}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<TeachersTable
reloadRows={reloadRows}
rows={f}
/>
</Box>
<Divider />
<TeachersPagination
count={recordCount}
page={currentPage}
rowsPerPage={rowsPerPage}
setPage={setCurrentPage}
setRowsPerPage={setRowsPerPage}
/>
</Card>
</TeachersSelectionProvider>
</Stack>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify(f, null, 2)}</pre>
</Box>
</Box>
);
}
interface PageProps {
searchParams: {
email?: string;
phone?: string;
sortDir?: 'asc' | 'desc';
status?: string;
//
};
}

View File

@@ -1,3 +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/user_metas/edit/[id]/page.tsx.draft` to handle `UserMeta` record thanks, modify comments/variables/paths/functions name please
e.g. why `lessonCategories` still exist ?

View File

@@ -1,8 +1,9 @@
'use client';
// src/app/dashboard/user_metas/edit/[id]/page.tsx
// src/app/dashboard/user_metas/edit/[id]/page.tsx
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams } from 'next/navigation';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
@@ -11,11 +12,12 @@ import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/Arrow
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
import { UserActivationEditForm } from '@/components/dashboard/user_meta/user-activation-edit-form';
import { UserMetaEditForm } from '@/components/dashboard/user_meta/user-meta-edit-form';
export default function Page(): React.JSX.Element {
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['user_metas']);
const { id: userId } = useParams<{ id: string }>();
React.useEffect(() => {
// console.log('helloworld');
@@ -49,6 +51,7 @@ export default function Page(): React.JSX.Element {
</div>
</Stack>
<UserMetaEditForm />
<UserActivationEditForm userId={userId} />
</Stack>
</Box>
);

View File

@@ -15,12 +15,6 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import type { ListResult, RecordModel } from 'pocketbase';
import { UserMetasFilters } from '@/components/dashboard/user_meta/user-metas-filters';
import { UserMetasPagination } from '@/components/dashboard/user_meta/user-metas-pagination';
import { UserMetasSelectionProvider } from '@/components/dashboard/user_meta/user-metas-selection-context';
import { UserMetasTable } from '@/components/dashboard/user_meta/user-metas-table';
import type { UserMeta } from '@/components/dashboard/user_meta/type.d';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
@@ -29,10 +23,15 @@ import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import ErrorDisplay from '@/components/dashboard/error';
import { defaultUserMeta } from '@/components/dashboard/user_meta/_constants';
import type { UserMeta } from '@/components/dashboard/user_meta/type.d';
import { UserMetasFilters } from '@/components/dashboard/user_meta/user-metas-filters';
import { UserMetasPagination } from '@/components/dashboard/user_meta/user-metas-pagination';
import { UserMetasSelectionProvider } from '@/components/dashboard/user_meta/user-metas-selection-context';
import { UserMetasTable } from '@/components/dashboard/user_meta/user-metas-table';
import FormLoading from '@/components/loading';
export default function Page({ searchParams }: PageProps): React.JSX.Element {
const { t } = useTranslation(['teachers']);
const { t } = useTranslation(['user_metas']);
const router = useRouter();
const { email, phone, sortDir, status } = searchParams;

View File

@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next';
import { PropertyItem } from '@/components/core/property-item';
import { PropertyList } from '@/components/core/property-list';
// import { CrCategory } from '@/components/dashboard/cr/categories/type';
import type { UserMeta } from '@/components/dashboard/user_meta/type.d';
import type { UserMeta } from '@/components/dashboard/user_meta/type_move.d';
export default function BasicDetailCard({
userMeta,

View File

@@ -1,5 +1,9 @@
'use client';
// src/app/dashboard/user_metas/view/[id]/page.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
@@ -7,7 +11,7 @@ import SampleAddressCard from '@/app/dashboard/Sample/AddressCard';
import { SampleNotifications } from '@/app/dashboard/Sample/Notifications';
import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard';
import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard';
import { COL_USER_METAS } from '@/constants';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
@@ -21,16 +25,14 @@ import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import { toast } from '@/components/core/toaster';
import ErrorDisplay from '@/components/dashboard/error';
import { defaultUserMeta } from '@/components/dashboard/user_meta/_constants';
import { Notifications } from '@/components/dashboard/user_meta/notifications';
import type { UserMeta } from '@/components/dashboard/user_meta/type_move.d';
import FormLoading from '@/components/loading';
import BasicDetailCard from './BasicDetailCard';
import TitleCard from './TitleCard';
import { defaultUserMeta } from '@/components/dashboard/user_meta/_constants';
import type { UserMeta } from '@/components/dashboard/user_meta/type.d';
import { COL_USER_METAS } from '@/constants';
export default function Page(): React.JSX.Element {
const { t } = useTranslation();

View File

@@ -1,3 +1,6 @@
// PURPOSE
// T.B.A.
//
const helloworld = 'helloworld';
export { helloworld };

View File

@@ -1,14 +1,13 @@
'use client';
// src/components/dashboard/student/student-create-form.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
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 { LoadingButton } from '@mui/lab';
//
@@ -41,14 +40,10 @@ import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
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 { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import { CreateFormProps, Student } from './type.d';
import { CreateFormProps } from './type.d';
// TODO: review schema
const schema = zod.object({
@@ -135,11 +130,11 @@ export function StudentCreateForm(): React.JSX.Element {
// }
const record = await createStudent(tempCreate);
toast.success('Student created');
// router.push(paths.dashboard.students.view(record.id));
toast.success('student-created');
router.push(paths.dashboard.students.view(record.id));
} catch (err) {
logger.error(err);
toast.error('Failed to create Student');
toast.error('failed-to-create-student');
} finally {
setIsUpdating(false);
}

View File

@@ -1,12 +1,13 @@
'use client';
// src/components/dashboard/student/student-edit-form.tsx
// PURPOSE:
// handle change details for student collection
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
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';
@@ -40,12 +41,13 @@ import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import type { Student } from './type.d';
// TODO: review schema
const schema = zod.object({
@@ -116,8 +118,6 @@ export function StudentEditForm(): React.JSX.Element {
setIsUpdating(true);
const updateData = {
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
//
name: values.name,
email: values.email,
phone: values.phone,
@@ -125,16 +125,17 @@ export function StudentEditForm(): React.JSX.Element {
//
// billingAddress: values.billingAddress,
//
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
currency: values.currency,
taxId: values.taxId,
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
};
try {
// await pb.collection(COL_USER_METAS).update(studentId, updateData);
await UpdateStudentById(studentId, updateData);
toast.success('Student updated successfully');
//
toast.success(t('student-updated-successfully'));
router.push(paths.dashboard.students.list);
if (billingAddressId) {
@@ -142,7 +143,7 @@ export function StudentEditForm(): React.JSX.Element {
}
} catch (error) {
logger.error(error);
toast.error('Failed to update student');
toast.error(t('failed-to-update-student'));
} finally {
setIsUpdating(false);
}
@@ -176,22 +177,21 @@ export function StudentEditForm(): React.JSX.Element {
setShowLoading(true);
try {
const result = await getStudentById(id);
const result = (await getStudentById(id)) as unknown as Student;
//
reset({ ...defaultValues, ...result });
setBillingAddressId(result.billingAddress.id);
if (result.avatar) {
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);
toast.error('Failed to load student data');
toast.error(t('failed-to-load-student-data'));
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
@@ -365,6 +365,7 @@ export function StudentEditForm(): React.JSX.Element {
)}
/>
</Grid>
{/* */}
</Grid>
</Stack>
{/* */}

View File

@@ -1,20 +1,67 @@
'use client';
// src/components/dashboard/student/type.d.tsx
// RULES: sorting direction for student lists
import type { BillingAddress } from '@/db/billingAddress/type';
// RULES: sorting direction for teacher lists
export type SortDir = 'asc' | 'desc';
export interface DBStudent {
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 student data structure
export interface Student {
id: string;
collectionId: string;
name: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone?: string;
quota: number;
company?: string;
//
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;
//
id: string;
createdAt: Date;
updatedAt?: Date;
collectionId: string;
}
// RULES: form data structure for creating new student
@@ -23,6 +70,7 @@ export interface CreateFormProps {
email: string;
phone?: string;
company?: string;
//
// handle seperately
// billingAddress?: {
// country: string;
@@ -32,6 +80,7 @@ export interface CreateFormProps {
// line1: string;
// line2?: string;
// };
//
taxId?: string;
timezone: string;
language: string;
@@ -64,6 +113,7 @@ export interface EditFormProps {
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
}
// RULES: filter props for student search and filtering
export interface CustomersFiltersProps {
filters?: Filters;

View File

@@ -1,9 +1,19 @@
'use client';
// src/components/dashboard/teacher/teacher-create-form.tsx
// PURPOSE
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { createTeacher } from '@/db/Teachers/Create';
import { getTeacherById } from '@/db/Teachers/GetById';
import { UpdateTeacherById } from '@/db/Teachers/UpdateById';
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';
@@ -16,41 +26,38 @@ import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
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 Grid from '@mui/material/Unstable_Grid2';
//
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
//
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
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 { toast } from '@/components/core/toaster';
import { createTeacher } from '@/db/Teachers/Create';
import isDevelopment from '@/lib/check-is-development';
import FormLoading from '@/components/loading';
function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = () => {
reject(new Error('Error converting file to base64'));
};
});
}
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import { CreateFormProps } from './type.d';
// TODO: review schema
const schema = zod.object({
avatar: zod.string().optional(),
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(15),
company: zod.string().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),
@@ -63,12 +70,12 @@ const schema = zod.object({
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 = {
avatar: '',
name: 'new name',
email: '123@123.com',
phone: '91234567',
@@ -85,10 +92,18 @@ const defaultValues = {
timezone: 'new_york',
language: 'en',
currency: 'USD',
avatar: '',
} satisfies Values;
export function TeacherCreateForm(): React.JSX.Element {
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 {
control,
@@ -100,14 +115,31 @@ export function TeacherCreateForm(): React.JSX.Element {
const onSubmit = React.useCallback(
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 {
// Use standard create method from db/Customers/Create
const record = await createTeacher(values);
toast.success('Customer created');
const record = await createTeacher(tempCreate);
toast.success('teacher-created');
router.push(paths.dashboard.teachers.details(record.id));
} catch (err) {
logger.error(err);
toast.error('Failed to create customer');
toast.error('failed-to-create-teacher');
} finally {
setIsUpdating(false);
}
},
[router]
@@ -137,7 +169,7 @@ export function TeacherCreateForm(): React.JSX.Element {
spacing={4}
>
<Stack spacing={3}>
<Typography variant="h6">Account information</Typography>
<Typography variant="h6">{t('create.basic-info')}</Typography>
<Grid
container
spacing={3}
@@ -151,12 +183,13 @@ export function TeacherCreateForm(): React.JSX.Element {
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%',
borderRadius: '5%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
variant="rounded"
src={avatar}
sx={{
'--Avatar-size': '100px',
@@ -175,8 +208,8 @@ export function TeacherCreateForm(): React.JSX.Element {
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Typography variant="subtitle1">{t('create.avatar')}</Typography>
<Typography variant="caption">{t('create.avatarRequirements')}</Typography>
<Button
color="secondary"
onClick={() => {
@@ -184,7 +217,7 @@ export function TeacherCreateForm(): React.JSX.Element {
}}
variant="outlined"
>
Select
{t('create.avatar_select')}
</Button>
<input
hidden
@@ -226,7 +259,7 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email address</InputLabel>
<InputLabel required>{t('create.email-address')}</InputLabel>
<OutlinedInput
{...field}
type="email"
@@ -248,7 +281,7 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone number</InputLabel>
<InputLabel required>{t('create.phone-number')}</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
@@ -268,7 +301,10 @@ export function TeacherCreateForm(): React.JSX.Element {
fullWidth
>
<InputLabel>Company</InputLabel>
<OutlinedInput {...field} />
<OutlinedInput
{...field}
placeholder="no company name"
/>
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
@@ -276,8 +312,9 @@ export function TeacherCreateForm(): React.JSX.Element {
</Grid>
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing information</Typography>
<Typography variant="h6">{t('create.billing-information')}</Typography>
<Grid
container
spacing={3}
@@ -296,10 +333,12 @@ export function TeacherCreateForm(): React.JSX.Element {
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<Option value="">Choose a country</Option>
<Option value="us">United States</Option>
<Option value="de">Germany</Option>
<Option value="es">Spain</Option>
<MenuItem value="">Choose a country</MenuItem>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
<MenuItem value="DE">Germany</MenuItem>
<MenuItem value="ES">Spain</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText>
@@ -362,7 +401,7 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip code</InputLabel>
<InputLabel required>{t('create.zip-code')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText>
@@ -383,7 +422,7 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address</InputLabel>
<InputLabel required>{t('create.address-line-1')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText>
@@ -424,7 +463,7 @@ export function TeacherCreateForm(): React.JSX.Element {
/>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional information</Typography>
<Typography variant="h6">{t('create.additional-information')}</Typography>
<Grid
container
spacing={3}
@@ -443,10 +482,14 @@ export function TeacherCreateForm(): React.JSX.Element {
>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<Option value="">Select a timezone</Option>
<Option value="new_york">US - New York</Option>
<Option value="california">US - California</Option>
<Option value="london">UK - London</Option>
<MenuItem value="">Select a timezone</MenuItem>
<MenuItem value="Europe/London">London</MenuItem>
<MenuItem value="Asia/Tokyo">Tokyo</MenuItem>
<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>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
@@ -467,10 +510,11 @@ export function TeacherCreateForm(): React.JSX.Element {
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<Option value="">Select a language</Option>
<Option value="en">English</Option>
<Option value="es">Spanish</Option>
<Option value="de">German</Option>
<MenuItem value="">Select a language</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="de">German</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
@@ -489,12 +533,12 @@ export function TeacherCreateForm(): React.JSX.Element {
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel>Currency</InputLabel>
<InputLabel required>{t('create.currency')}</InputLabel>
<Select {...field}>
<Option value="">Select a currency</Option>
<Option value="USD">USD</Option>
<Option value="EUR">EUR</Option>
<Option value="RON">RON</Option>
<MenuItem value="">no currency selected</MenuItem>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
@@ -511,14 +555,17 @@ export function TeacherCreateForm(): React.JSX.Element {
component={RouterLink}
href={paths.dashboard.teachers.list}
>
Cancel
{t('create.cancelButton')}
</Button>
<Button
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
Create teacher
</Button>
{t('create.updateButton')}
</LoadingButton>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>

View File

@@ -1,12 +1,16 @@
'use client';
// src/components/dashboard/teacher/teacher-edit-form.tsx
// PURPOSE:
// handle change details for teachers collection
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_USER_METAS } from '@/constants';
import { UpdateBillingAddressById } from '@/db/billingAddress/UpdateById';
import { getTeacherById } from '@/db/Teachers/GetById';
import { UpdateTeacherById } from '@/db/Teachers/UpdateById';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
@@ -37,14 +41,15 @@ import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import type { Teacher } from './type.d';
// TODO: review this
// TODO: review schema
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),
@@ -89,7 +94,7 @@ const defaultValues = {
export function TeacherEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['teachers']);
const { id: teacherId } = useParams<{ id: string }>();
//
@@ -97,6 +102,7 @@ export function TeacherEditForm(): React.JSX.Element {
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const [billingAddressId, setBillingAddressId] = React.useState<string | null>(null);
const {
control,
@@ -116,7 +122,9 @@ export function TeacherEditForm(): React.JSX.Element {
email: values.email,
phone: values.phone,
company: values.company,
billingAddress: values.billingAddress,
//
// billingAddress: values.billingAddress,
//
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
@@ -125,12 +133,17 @@ export function TeacherEditForm(): React.JSX.Element {
};
try {
await pb.collection(COL_USER_METAS).update(teacherId, updateData);
toast.success('Teacher updated successfully');
await UpdateTeacherById(teacherId, updateData);
//
toast.success(t('teacher-updated-successfully'));
router.push(paths.dashboard.teachers.list);
if (billingAddressId) {
await UpdateBillingAddressById(billingAddressId, values.billingAddress);
}
} catch (error) {
logger.error(error);
toast.error('Failed to update teacher');
toast.error(t('failed-to-update-teacher'));
} finally {
setIsUpdating(false);
}
@@ -164,21 +177,21 @@ export function TeacherEditForm(): React.JSX.Element {
setShowLoading(true);
try {
const result = await pb.collection(COL_USER_METAS).getOne(id);
const result = (await getTeacherById(id)) as unknown as Teacher;
//
reset({ ...defaultValues, ...result });
console.log({ result });
setBillingAddressId(result.billingAddress.id);
if (result.avatar) {
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);
toast.error('Failed to load teacher data');
toast.error(t('failed-to-load-teacher-data'));
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
@@ -301,7 +314,7 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email</InputLabel>
<InputLabel required>{t('edit.email-address')}</InputLabel>
<OutlinedInput
{...field}
type="email"
@@ -323,7 +336,7 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone</InputLabel>
<InputLabel required>{t('edit.phone-number')}</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
@@ -352,11 +365,12 @@ export function TeacherEditForm(): React.JSX.Element {
)}
/>
</Grid>
{/* */}
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing Information</Typography>
<Typography variant="h6">{t('edit.billing-information')}</Typography>
<Grid
container
spacing={3}
@@ -375,9 +389,12 @@ export function TeacherEditForm(): React.JSX.Element {
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<MenuItem value="">No Country selected</MenuItem>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
<MenuItem value="DE">Germany</MenuItem>
<MenuItem value="ES">Spain</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
@@ -440,7 +457,7 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip Code</InputLabel>
<InputLabel required>{t('edit.zip-code')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
@@ -461,7 +478,7 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address Line 1</InputLabel>
<InputLabel required>{t('edit.address-line-1')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
@@ -496,7 +513,7 @@ export function TeacherEditForm(): React.JSX.Element {
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography>
<Typography variant="h6">{t('edit.additional-information')}</Typography>
<Grid
container
spacing={3}
@@ -543,8 +560,10 @@ export function TeacherEditForm(): React.JSX.Element {
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<MenuItem value="">no language selected</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="de">German</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
@@ -564,8 +583,9 @@ export function TeacherEditForm(): React.JSX.Element {
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel required>Currency</InputLabel>
<InputLabel required>{t('edit.currency')}</InputLabel>
<Select {...field}>
<MenuItem value="">no currency selected</MenuItem>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>

View File

@@ -1,5 +1,9 @@
'use client';
// src/components/dashboard/teacher/teachers-table.tsx
// PURPOSE:
// handle change details for teachers collection
//
import * as React from 'react';
import RouterLink from 'next/link';
import { LoadingButton } from '@mui/lab';
@@ -213,7 +217,6 @@ export function TeachersTable({ rows, reloadRows }: TeachersTableProps): React.J
sx={{ textAlign: 'center' }}
variant="body2"
>
{/* TODO: update this */}
{t('no-teachers-found')}
</Typography>
</Box>

View File

@@ -31,21 +31,23 @@ export interface CreateFormProps {
email: string;
phone?: string;
company?: string;
billingAddress?: {
country: string;
state: string;
city: string;
zipCode: string;
line1: string;
line2?: string;
};
// handle seperately
// billingAddress?: {
// country: string;
// state: string;
// city: string;
// zipCode: string;
// line1: string;
// line2?: string;
// };
taxId?: string;
timezone: string;
language: string;
currency: string;
avatar?: string;
avatar?: File | null;
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
state?: 'pending' | 'active' | 'blocked';
meta: Record<string, null>;
}
// RULES: form data structure for editing existing teacher
@@ -77,6 +79,8 @@ export interface TeachersFiltersProps {
sortDir?: SortDir;
fullData: Teacher[];
}
// RULES: available filter options for student data
export interface Filters {
email?: string;
phone?: string;

View File

@@ -3,6 +3,7 @@
// empty valur for customer
import { dayjs } from '@/lib/dayjs';
import type { UserMeta } from './type.d';
export const defaultUserMeta: UserMeta = {

View File

@@ -1,26 +1,11 @@
'use client';
// src/components/dashboard/user_meta/type.d.tsx
// RULES: sorting direction for user meta lists
import type { BillingAddress } from '@/db/billingAddress/type';
// RULES: sorting direction for teacher lists
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;
//
@@ -50,7 +35,7 @@ export interface DBUserMeta {
collectionId: string;
}
// RULES: core teacher data structure
// RULES: core user meta data structure
export interface UserMeta {
name: string;
//
@@ -72,7 +57,6 @@ export interface UserMeta {
timezone: string;
language: string;
currency: string;
//
id: string;
createdAt: Date;
@@ -80,12 +64,14 @@ export interface UserMeta {
collectionId: string;
}
// RULES: form data structure for creating new teacher
// RULES: form data structure for creating new user meta
export interface CreateFormProps {
name: string;
email: string;
phone?: string;
company?: string;
//
// handle seperately ?
billingAddress?: {
country: string;
state: string;
@@ -94,6 +80,7 @@ export interface CreateFormProps {
line1: string;
line2?: string;
};
//
taxId?: string;
timezone: string;
language: string;
@@ -103,7 +90,7 @@ export interface CreateFormProps {
// status?: 'pending' | 'active' | 'blocked';
}
// RULES: form data structure for editing existing teacher
// RULES: form data structure for editing existing user meta
export interface EditFormProps {
name: string;
email: string;
@@ -126,12 +113,13 @@ export interface EditFormProps {
// status?: 'pending' | 'active' | 'blocked';
}
// RULES: filter props for teacher search and filtering
// RULES: filter props for user meta search and filtering
export interface UserMetasFiltersProps {
filters?: Filters;
sortDir?: SortDir;
fullData: UserMeta[];
}
// RULES: available filter options for user meta data
export interface Filters {
email?: string;
phone?: string;

View File

@@ -0,0 +1,193 @@
'use client';
//
// src/components/dashboard/user_meta/user-activation-edit-form.tsx
// RULES
// handle user change activation of other users
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_USERS } from '@/constants';
import { getUserById } from '@/db/Users/GetById';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
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 Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
//
//
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
// TODO: review this
const schema = zod.object({
verified: zod.string(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
verified: 'false',
} satisfies Values;
export function UserActivationEditForm({ userId }: { userId: string }): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['user_metas']);
const { id: userMetaId } = useParams<{ id: string }>();
//
const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const {
control,
handleSubmit,
formState: { errors },
setValue,
reset,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
setIsUpdating(true);
const updateData = {
verified: false,
};
try {
await pb.collection(COL_USERS).update(userId, updateData);
toast.success(t('user-updated-successfully'));
// router.push(paths.dashboard.user_metas.list);
} catch (error) {
logger.error(error);
toast.error(t('failed-to-update-user-meta'));
} finally {
setIsUpdating(false);
}
},
[userMetaId, router]
);
// TODO: need to align with save form
// use trycatch
const [textDescription, setTextDescription] = React.useState<string>('');
const [textRemarks, setTextRemarks] = React.useState<string>('');
// load existing data when user arrive
const loadExistingData = React.useCallback(
async (id: string) => {
try {
const result = await getUserById(userId);
reset({ verified: result.verified.toString() });
setShowLoading(false);
} catch (error) {
logger.error(error);
toast.error('failed-to-load-user-meta-data');
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
}
},
[reset, setValue]
);
React.useEffect(() => {
void loadExistingData(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack
divider={<Divider />}
spacing={4}
>
<Controller
control={control}
name="verified"
render={({ field }) => (
<FormControl
error={Boolean(errors.verified)}
fullWidth
>
<InputLabel required>
{t('user-activation')} {t('optional')}
</InputLabel>
<Select {...field}>
<MenuItem value="true">{t('activated')}</MenuItem>
<MenuItem value="false">{t('not-actviate')}</MenuItem>
</Select>
{errors.verified ? <FormHelperText>{errors.verified.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<div>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.user_metas.list}
>
{t('edit.cancelButton')}
</Button>
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
{t('edit.updateButton')}
</LoadingButton>
</div>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form>
);
}

View File

@@ -1,12 +1,13 @@
'use client';
// src/components/dashboard/user_meta/user-meta-edit-form.tsx
// PURPOSE:
// handle change details for user meta collection
//
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
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';
@@ -40,14 +41,15 @@ import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import type { UserMeta } from './type.d';
// TODO: review this
// TODO: review schema
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),
@@ -92,7 +94,7 @@ const defaultValues = {
export function UserMetaEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { t } = useTranslation(['user_metas']);
const { id: userMetaId } = useParams<{ id: string }>();
//
@@ -100,6 +102,7 @@ export function UserMetaEditForm(): React.JSX.Element {
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const [billingAddressId, setBillingAddressId] = React.useState<string | null>(null);
const {
control,
@@ -119,7 +122,9 @@ export function UserMetaEditForm(): React.JSX.Element {
email: values.email,
phone: values.phone,
company: values.company,
billingAddress: values.billingAddress,
//
// billingAddress: values.billingAddress,
//
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
@@ -128,12 +133,17 @@ export function UserMetaEditForm(): React.JSX.Element {
};
try {
await pb.collection(COL_USER_METAS).update(userMetaId, updateData);
toast.success('Teacher updated successfully');
router.push(paths.dashboard.teachers.list);
await UpdateUserMetaById(userMetaId, updateData);
//
toast.success(t('user-updated-successfully'));
router.push(paths.dashboard.user_metas.list);
if (billingAddressId) {
await UpdateBillingAddressById(billingAddressId, values.billingAddress);
}
} catch (error) {
logger.error(error);
toast.error('Failed to update teacher');
toast.error(t('failed-to-update-user-meta'));
} finally {
setIsUpdating(false);
}
@@ -167,21 +177,21 @@ export function UserMetaEditForm(): React.JSX.Element {
setShowLoading(true);
try {
const result = await pb.collection(COL_USER_METAS).getOne(id);
const result = (await getUserMetaById(id)) as unknown as UserMeta;
//
reset({ ...defaultValues, ...result });
console.log({ result });
setBillingAddressId(result.billingAddress.id);
if (result.avatar) {
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);
toast.error('Failed to load teacher data');
toast.error(t('failed-to-load-user-meta-data'));
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
@@ -304,7 +314,7 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email</InputLabel>
<InputLabel required>{t('edit.email-address')}</InputLabel>
<OutlinedInput
{...field}
type="email"
@@ -326,7 +336,7 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone</InputLabel>
<InputLabel required>{t('edit.phone-number')}</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
@@ -355,11 +365,12 @@ export function UserMetaEditForm(): React.JSX.Element {
)}
/>
</Grid>
{/* */}
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing Information</Typography>
<Typography variant="h6">{t('edit.billing-information')}</Typography>
<Grid
container
spacing={3}
@@ -378,9 +389,12 @@ export function UserMetaEditForm(): React.JSX.Element {
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<MenuItem value="">No Country selected</MenuItem>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
<MenuItem value="DE">Germany</MenuItem>
<MenuItem value="ES">Spain</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
@@ -443,7 +457,7 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip Code</InputLabel>
<InputLabel required>{t('edit.zip-code')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
@@ -464,7 +478,7 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address Line 1</InputLabel>
<InputLabel required>{t('edit.address-line-1')}</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
@@ -499,7 +513,7 @@ export function UserMetaEditForm(): React.JSX.Element {
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography>
<Typography variant="h6">{t('edit.additional-information')}</Typography>
<Grid
container
spacing={3}
@@ -546,8 +560,10 @@ export function UserMetaEditForm(): React.JSX.Element {
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<MenuItem value="">no language selected</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="de">German</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
@@ -567,8 +583,9 @@ export function UserMetaEditForm(): React.JSX.Element {
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel required>Currency</InputLabel>
<InputLabel required>{t('edit.currency')}</InputLabel>
<Select {...field}>
<MenuItem value="">no currency selected</MenuItem>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>
@@ -586,7 +603,7 @@ export function UserMetaEditForm(): React.JSX.Element {
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.teachers.list}
href={paths.dashboard.user_metas.list}
>
{t('edit.cancelButton')}
</Button>

View File

@@ -1,11 +1,15 @@
'use client';
// src/components/dashboard/user_meta/user-metas-filters.tsx
// RULES:
// T.B.A.
//
import * as React from 'react';
import { useRouter } from 'next/navigation';
import GetActiveCount from '@/db/UserMetas/GetActiveCount';
import getAllUserMetasCount from '@/db/UserMetas/GetAllCount';
import GetBlockedCount from '@/db/UserMetas/GetBlockedCount';
import GetPendingCount from '@/db/UserMetas/GetPendingCount';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
@@ -18,17 +22,14 @@ import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { FilterButton } from '@/components/core/filter-button';
import { Option } from '@/components/core/option';
import { useUserMetasSelection } from './user-metas-selection-context';
import GetBlockedCount from '@/db/UserMetas/GetBlockedCount';
import GetPendingCount from '@/db/UserMetas/GetPendingCount';
import GetActiveCount from '@/db/UserMetas/GetActiveCount';
import PhoneFilterPopover from './phone-filter-popover';
import EmailFilterPopover from './email-filter-popover';
import type { UserMetasFiltersProps, Filters, SortDir } from './type.d';
import { logger } from '@/lib/default-logger';
import PhoneFilterPopover from './phone-filter-popover';
import type { Filters, SortDir, UserMetasFiltersProps } from './type.d';
import { useUserMetasSelection } from './user-metas-selection-context';
export function UserMetasFilters({
filters = {},

View File

@@ -1,5 +1,9 @@
'use client';
// src/components/dashboard/user_meta/user-metas-table.tsx
// RULES:
// T.B.A.
//
import * as React from 'react';
import RouterLink from 'next/link';
import { LoadingButton } from '@mui/lab';
@@ -18,14 +22,16 @@ import { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images';
import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
import { DataTable } from '@/components/core/data-table';
import type { ColumnDef } from '@/components/core/data-table';
import ConfirmDeleteModal from './confirm-delete-modal';
import { useUserMetasSelection } from './user-metas-selection-context';
import type { UserMeta } from './type.d';
import { useUserMetasSelection } from './user-metas-selection-context';
function columns(handleDeleteClick: (userMetaId: string) => void): ColumnDef<UserMeta>[] {
return [
@@ -168,6 +174,7 @@ export interface UserMetasTableProps {
}
export function UserMetasTable({ rows, reloadRows }: UserMetasTableProps): React.JSX.Element {
const { t } = useTranslation(['user_metas']);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useUserMetasSelection();
const [idToDelete, setIdToDelete] = React.useState('');
@@ -207,7 +214,7 @@ export function UserMetasTable({ rows, reloadRows }: UserMetasTableProps): React
sx={{ textAlign: 'center' }}
variant="body2"
>
No user metadata found
{t('no-user-meta-found')}
</Typography>
</Box>
) : null}

View File

@@ -1,12 +1,12 @@
// api method for crate student record
// RULES:
// TBA
import { COL_STUDENTS, COL_USER_METAS } from '@/constants';
import { COL_USER_METAS } from '@/constants';
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> {
return pb.collection(COL_USER_METAS).create(data);
return pb.collection(COL_USER_METAS).create({ ...data, role: 'student' });
}

View File

@@ -1,12 +1,16 @@
// src/db/Students/GetById.tsx
//
import { COL_USER_METAS } from '@/constants';
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type.d';
import type { DBStudent, Student } from '@/components/dashboard/student/type';
export async function getStudentById(id: string): Promise<UserMeta> {
const record = await pb.collection(COL_USER_METAS).getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld' });
export async function getStudentById(id: string): Promise<Student> {
const record = await pb
.collection(COL_USER_METAS)
.getOne<DBStudent>(id, { expand: 'billingAddress, helloworld', requestKey: null });
const temp: UserMeta = {
const temp: Student = {
id: record.id,
name: record.name,
email: record.email,

View File

@@ -1,3 +1,7 @@
// src/db/Students/Helloworld.tsx
// RULES:
// T.B.A.
//
export function helloCustomer() {
return 'Hello from Customers module!';
}

View File

@@ -1,15 +1,71 @@
import type { BillingAddress } from '@/components/dashboard/user_meta/type.d';
// src/db/Students/type.d.ts
//
// PURPOSE
// type for student record
//
// RULES: sorting direction for user meta lists
import type { BillingAddress } from '../billingAddress/type';
// Student type definitions
export interface Student {
id: string;
export interface DBStudentOld {
//
name: string;
avatar: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone: string;
quota: number;
status: 'active' | 'blocked' | 'pending';
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 user meta data structure
export interface Student {
id: string;
name: string;
//
// NOTE: obslete "avatar" and use "avatar_file"
avatar?: string;
avatar_file?: string;
//
email: string;
phone?: string;
quota: number;
company?: string;
//
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;
//
id: string;
createdAt: Date;
updatedAt?: Date;
collectionId: string;
}
export interface UpdateStudent {
@@ -34,6 +90,7 @@ export interface UpdateStudent {
timezone?: string;
language?: string;
currency?: string;
//
taxId?: string;
}

View File

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

View File

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

View File

@@ -1,7 +1,30 @@
import { pb } from '@/lib/pb';
import { COL_TEACHERS } from '@/constants';
import { RecordModel } from 'pocketbase';
// src/db/Teachers/GetById.tsx
//
import { COL_USER_METAS } from '@/constants';
export async function getTeacherById(id: string): Promise<RecordModel> {
return pb.collection(COL_TEACHERS).getOne(id);
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type.d';
export async function getTeacherById(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 { UpdateTeacher } from './type';
export async function UpdateTeacherById(id: string, data: Partial<UpdateTeacher>): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).update(id, data);
}

View File

@@ -0,0 +1,41 @@
//
// RULES
// type for teacher record
// Teacher type definitions
export interface Teacher {
id: string;
name: string;
avatar: string;
email: string;
phone: string;
quota: number;
status: 'active' | 'blocked' | 'pending';
createdAt: Date;
}
export interface UpdateTeacher {
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

@@ -1,11 +1,14 @@
// api method for crate customer record
// RULES:
// TBA
import { pb } from '@/lib/pb';
// src/db/UserMetas/Create.tsx
//
// PURPOSE:
// create user meta
//
import { COL_USER_METAS } from '@/constants';
import type { CreateFormProps } from '@/components/dashboard/user_meta/type.d';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pb';
import type { CreateFormProps } from '@/components/dashboard/user_meta/type.d';
export async function createUserMeta(data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).create(data);
}

View File

@@ -1,7 +1,32 @@
import { pb } from '@/lib/pb';
// src/db/UserMetas/GetById.tsx
//
import { COL_USER_METAS } from '@/constants';
import { RecordModel } from 'pocketbase';
export async function getUserMetaById(id: string): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).getOne(id);
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type';
export async function getUserMetaById(id: string): Promise<UserMeta> {
const record = await pb
.collection(COL_USER_METAS)
.getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld', requestKey: null });
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

@@ -1,5 +1,38 @@
// src/db/UserMetas/type.d.ts
//
// RULES: sorting direction for user meta lists
import type { BillingAddress } from '@/components/dashboard/user_meta/type.d';
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;
}
// UserMeta type definitions
export interface UserMeta {
id: string;

View File

@@ -1,15 +0,0 @@
// REQ0006
import { COL_USERS } from '@/constants';
import { pb } from '@/lib/pb';
export default async function GetAllCount(): Promise<number> {
try {
const result = await pb.collection(`users`).getList(1, 9999, { filter: 'email != ""' });
const { totalItems: count } = result;
return count;
} catch (error) {
console.error(error);
return -99;
}
}

View File

@@ -1,15 +1,9 @@
import { pb } from '@/lib/pb';
import { COL_USERS } from '@/constants';
import type { User } from '@/types/user';
export async function getUserById(id: string): Promise<User> {
try {
const user = await pb.collection(COL_USERS).getOne<User>(id);
return user;
} catch (err) {
if (err instanceof Error && err.message.includes('404')) {
throw new Error(`User with ID ${id} not found`);
}
throw err;
}
import { pb } from '@/lib/pb';
import type { User } from './type.d';
export function getUserById(id: string): Promise<User> {
return pb.collection(COL_USERS).getOne<User>(id);
}

View File

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

View File

@@ -21,9 +21,12 @@ the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-onlin
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_USERS } from '@/constants';
import { pb } from '@/lib/pb';
import type { User } from './type.d';
export async function createUser(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))

15
002_source/cms/src/db/Users/type.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
//
// RULES
// pocketbase Users collection schema
//
// User type definitions
export interface User {
verified: boolean;
//
id: string;
createdAt: Date;
}
export interface UpdateUser {
verified?: boolean;
}

View File

@@ -1,7 +1,11 @@
import { COL_BILLING_ADDRESS, COL_STUDENTS, COL_USER_METAS } from '@/constants';
import { RecordModel } from 'pocketbase';
// src/db/billingAddress/GetById.tsx
//
// PURPOSE:
// to get billing address by its id
//
import { COL_BILLING_ADDRESS } from '@/constants';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import type { DBUserMeta, UserMeta } from '@/components/dashboard/user_meta/type.d';
@@ -10,8 +14,6 @@ export async function getBillingAddressById(id: string): Promise<UserMeta> {
.collection(COL_BILLING_ADDRESS)
.getOne<DBUserMeta>(id, { expand: 'billingAddress, helloworld' });
console.log({ record });
const temp: UserMeta = {
id: record.id,
name: record.name,

View File

@@ -1,9 +1,12 @@
'use client';
// src/lib/auth/custom/client.ts
//
import { getUserMetaById } from '@/db/UserMetas/GetById';
import type { User } from '@/types/user';
import { logger } from '@/lib/default-logger';
import { pb } from '@/lib/pb';
import type { User } from '@/types/user';
function generateToken(): string {
const arr = new Uint8Array(12);
@@ -11,14 +14,6 @@ function generateToken(): string {
return Array.from(arr, (v) => v.toString(16).padStart(2, '0')).join('');
}
const user_xxx = {
id: 'USR-000',
avatar: '/assets/avatar.png',
firstName: 'Sofia',
lastName: 'Rivers',
email: 'sofia@devias.io',
} satisfies User;
export interface SignUpParams {
firstName: string;
lastName: string;

View File

@@ -1,3 +1,8 @@
export default function getImageUrlFromFile(collectionId: string, id: string, catImage: string): string {
return `http://127.0.0.1:8090/api/files/${collectionId}/${id}/${catImage}`;
//
// PURPOSE:
// get file url from pocketbase record
//
export default function getImageUrlFromFile(collectionId: string, id: string, imgFile: string): string {
return `http://127.0.0.1:8090/api/files/${collectionId}/${id}/${imgFile}`;
}

View File

@@ -1,3 +1,7 @@
// src/lib/helloworld.ts
// RULES:
// T.B.A.
//
export function helloworld(): string {
return 'Helloworld';
}

View File

@@ -140,7 +140,7 @@ export const paths = {
list: '/dashboard/teachers/list',
create: '/dashboard/teachers/create',
details: (id: string) => `/dashboard/teachers/view/${id}`,
view: (id: string) => `/dashboard/students/view/${id}`,
view: (id: string) => `/dashboard/teachers/view/${id}`,
edit: (id: string) => `/dashboard/teachers/edit/${id}`,
mail: {
list: (id: string) => `/dashboard/teachers/mail/${id}/list`,

View File

@@ -1,3 +1,4 @@
# TODO
- [ ] add login mechanism
- [ ] add task server handle callback tasks

View File

@@ -16,6 +16,7 @@
"@capacitor/keyboard": "6.0.3",
"@capacitor/splash-screen": "^6.0.3",
"@capacitor/status-bar": "6.0.2",
"@hookform/resolvers": "3.3.4",
"@ionic/prettier-config": "^4.0.0",
"@ionic/react": "^8.0.0",
"@ionic/react-router": "^8.0.0",
@@ -23,21 +24,25 @@
"@lifeomic/attempt": "^3.1.0",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6",
"@types/lodash": "^4.17.16",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.8.1",
"i18next": "^24.2.2",
"ionicons": "^7.0.0",
"lodash": "^4.17.21",
"pocketbase": "^0.26.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "7.50.1",
"react-i18next": "^15.4.1",
"react-markdown": "^9.0.3",
"react-router": "^5.3.4",
"react-router-dom": "^5.3.4",
"react-use": "^17.6.0",
"react-use-audio-player": "^2.3.0-alpha.1",
"remark-gfm": "^4.0.0"
"remark-gfm": "^4.0.0",
"zod": "3.22.4"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
@@ -65,7 +70,7 @@
"vitest": "^0.34.6"
},
"engines": {
"node": "==18"
"node": "==22"
}
},
"node_modules/@adobe/css-tools": {
@@ -2242,6 +2247,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@hookform/resolvers": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.4.tgz",
"integrity": "sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -3699,6 +3713,12 @@
"integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.16",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -10602,7 +10622,6 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -13138,6 +13157,22 @@
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
"version": "7.50.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.50.1.tgz",
"integrity": "sha512-3PCY82oE0WgeOgUtIr3nYNNtNvqtJ7BZjsbxh6TnYNbXButaD5WpjOmTjdxZfheuHKR68qfeFnEDVYoSSFPMTQ==",
"license": "MIT",
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-i18next": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz",
@@ -16747,6 +16782,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -31,6 +31,7 @@
"@capacitor/keyboard": "6.0.3",
"@capacitor/splash-screen": "^6.0.3",
"@capacitor/status-bar": "6.0.2",
"@hookform/resolvers": "3.3.4",
"@ionic/prettier-config": "^4.0.0",
"@ionic/react": "^8.0.0",
"@ionic/react-router": "^8.0.0",
@@ -38,21 +39,25 @@
"@lifeomic/attempt": "^3.1.0",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6",
"@types/lodash": "^4.17.16",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.8.1",
"i18next": "^24.2.2",
"ionicons": "^7.0.0",
"lodash": "^4.17.21",
"pocketbase": "^0.26.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "7.50.1",
"react-i18next": "^15.4.1",
"react-markdown": "^9.0.3",
"react-router": "^5.3.4",
"react-router-dom": "^5.3.4",
"react-use": "^17.6.0",
"react-use-audio-player": "^2.3.0-alpha.1",
"remark-gfm": "^4.0.0"
"remark-gfm": "^4.0.0",
"zod": "3.22.4"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
@@ -81,6 +86,6 @@
},
"description": "An Ionic project",
"engines": {
"node": "==18"
"node": "==22"
}
}

View File

@@ -0,0 +1,16 @@
const Paths = {
AuthHome: `/auth/home`,
AuthLogin: `/auth/login`,
AuthSignUp: `/auth/signup`,
SignUpSuccess: `/auth/sign_up_success`,
//
StudentMenu: `/auth/student_menu`,
StudentInfo: `/auth/student_info/:id`,
GetStudentInfoLink: (id: string) => `/auth/student_info/${id}`,
//
AuthorizedTest: `/auth/authorized_test`,
//
Setting: `/setting`,
};
export { Paths };

View File

@@ -45,10 +45,17 @@ import Page from './pages/Page';
import QuizzesMainMenu from './pages/QuizzesMainMenu';
//
import MyAchievementPage from './pages/Record/index';
import Setting from './pages/Setting/indx';
import Setting from './pages/Setting';
import Tab1 from './pages/Tab1';
import Tab2 from './pages/Tab2';
import Tab3 from './pages/Tab3';
import { Paths } from './Paths';
import SignUpSuccess from './pages/auth/SignUpSuccess';
import AuthorizedTest from './pages/auth/AuthorizedTest';
import { AuthGuard } from './components/auth/auth-guard';
import StudentInfo from './pages/auth/StudentInfo';
import StudentMenu from './pages/auth/StudentMenu';
// import { AuthGuard } from './pages/auth/AuthorizedTest/auth-guard';
// import WordPageWithLayout from './pages/Lesson/WordPageWithLayout.del';
function RouteConfig() {
@@ -160,18 +167,35 @@ function RouteConfig() {
<ConnectivesPage />
</Route>
<Route exact path={`/auth/Home`}>
<Route exact path={Paths.AuthHome}>
<AuthHome />
</Route>
<Route exact path={`/auth/login`}>
<Route exact path={Paths.AuthLogin}>
<AuthLogin />
</Route>
<Route exact path={`/auth/signup`}>
<Route exact path={Paths.AuthSignUp}>
<AuthSignUp />
</Route>
<Route exact path={Paths.SignUpSuccess}>
<SignUpSuccess />
</Route>
{/* protected page */}
<AuthGuard>
<Route exact path={Paths.StudentInfo}>
<StudentInfo />
</Route>
<Route exact path={Paths.StudentMenu}>
<StudentMenu />
</Route>
<Route exact path={Paths.AuthorizedTest}>
<AuthorizedTest />
</Route>
</AuthGuard>
{/* TODO: remove below */}
<Route exact path="/tab1">
<Tab1 />
@@ -182,7 +206,7 @@ function RouteConfig() {
<Route path="/tab3">
<Tab3 />
</Route>
<Route path="/setting">
<Route path={Paths.Setting}>
<Setting />
</Route>
<Route path="/page/:name" exact={true}>

View File

@@ -1,10 +1,7 @@
import { IonInput, IonLabel } from '@ionic/react';
import styles from './style.module.scss';
function CustomField({
field,
errors,
}: {
interface CustomFieldProps {
field: {
id: string;
label: string;
@@ -20,7 +17,9 @@ function CustomField({
};
};
errors: any;
}): React.JSX.Element {
}
function CustomField({ field, errors }: CustomFieldProps): 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;

View File

@@ -2,6 +2,8 @@ import { IonButton, IonIcon, useIonRouter } from '@ionic/react';
import { arrowBack } from 'ionicons/icons';
import { LESSON_LINK, VERSIONS } from '../../constants';
import SettingSvg from './image.svg';
import { Paths } from '../../Paths';
import { pb } from '../../lib/pb';
interface ContainerProps {
name: string;
@@ -9,6 +11,19 @@ interface ContainerProps {
const SettingContainer: React.FC<ContainerProps> = ({ name }) => {
const router = useIonRouter();
function handleAuthHomeClick() {
router.push(Paths.AuthHome);
}
function handleUserProfileClick() {
if (pb.authStore.record?.id) {
router.push(Paths.GetStudentInfoLink(pb.authStore.record.id));
} else {
router.push(Paths.AuthLogin);
}
}
return (
<div
style={{
@@ -34,6 +49,7 @@ const SettingContainer: React.FC<ContainerProps> = ({ name }) => {
<p>T.B.A.</p>
</div>
<div>{VERSIONS}</div>
<IonButton onClick={handleUserProfileClick}>User Profile</IonButton>
<IonButton
onClick={() => {
router.push(LESSON_LINK, undefined, 'replace');

View File

@@ -0,0 +1,52 @@
import { useIonRouter } from '@ionic/react';
import * as React from 'react';
import { IonAlert, IonButton } from '@ionic/react';
import { useUser } from '../../hooks/use-user';
import { Paths } from '../../Paths';
export interface AuthGuardProps {
children: React.ReactNode;
}
export function AuthGuard({ children }: AuthGuardProps): React.JSX.Element | null {
const router = useIonRouter();
const { user, error, isLoading } = useUser();
const [isChecking, setIsChecking] = React.useState<boolean>(true);
const checkPermissions = async (): Promise<void> => {
//
if (isLoading) {
return;
}
//
if (error) {
setIsChecking(false);
return;
}
// NOTE: here state that if user = null, eject user to login page
if (!user) {
// logger.debug('[AuthGuard]: User is not logged in, redirecting to sign in');
router.push(Paths.AuthLogin);
}
setIsChecking(false);
};
React.useEffect(() => {
checkPermissions().catch(() => {
// noop
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- Expected
}, [user, error, isLoading]);
if (isChecking) {
return null;
}
if (error) {
return <IonAlert color="error">{error}</IonAlert>;
}
return <React.Fragment>{children}</React.Fragment>;
}

View File

@@ -77,6 +77,10 @@ const MY_FAVORITE = 'My Favorite';
//
const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL;
//
// database constants
export const COL_USERS = 'users';
export const COL_USER_METAS = 'UserMetas';
export {
//

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
import { User } from '../../../types/user';
import { authClient } from '../../../lib/auth/custom/client';
import type { UserContextValue } from '../types';
export const UserContext = React.createContext<UserContextValue | undefined>(undefined);
export interface UserProviderProps {
children: React.ReactNode;
}
export function UserProvider({ children }: UserProviderProps): React.JSX.Element {
const [state, setState] = React.useState<{ user: User | null; error: string | null; isLoading: boolean }>({
user: null,
error: null,
isLoading: true,
});
const checkSession = React.useCallback(async (): Promise<void> => {
try {
const { data, error } = await authClient.getUser();
if (error) {
// logger.error(error);
setState((prev) => ({ ...prev, user: null, error: 'Something went wrong', isLoading: false }));
return;
}
setState((prev) => ({ ...prev, user: data ?? null, error: null, isLoading: false }));
} catch (err) {
// logger.error(err);
setState((prev) => ({ ...prev, user: null, error: 'Something went wrong', isLoading: false }));
}
}, []);
React.useEffect(() => {
checkSession().catch((err) => {
// logger.error(err);
// noop
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- Expected
}, []);
return (
<UserContext.Provider value={{ ...state, checkSession }}>
{/* */}
{children}
</UserContext.Provider>
);
}
export const UserConsumer = UserContext.Consumer;

View File

@@ -0,0 +1,8 @@
import type { User } from '../../types/user';
export interface UserContextValue {
user: User | null;
error: string | null;
isLoading: boolean;
checkSession?: () => Promise<void>;
}

View File

@@ -0,0 +1,16 @@
import type * as React from 'react';
import type { UserContextValue } from './types';
import { UserContext as CustomUserContext, UserProvider as CustomUserProvider } from './custom/user-context';
// eslint-disable-next-line import/no-mutable-exports -- Export based on config
let UserProvider: React.FC<{ children: React.ReactNode }>;
// eslint-disable-next-line import/no-mutable-exports -- Export based on config
let UserContext: React.Context<UserContextValue | undefined>;
UserContext = CustomUserContext;
UserProvider = CustomUserProvider;
export { UserProvider, UserContext };

View File

@@ -1,5 +1,6 @@
import { PocketBaseProvider } from '../hooks/usePocketBase';
import { AppStateProvider } from './AppState';
import { UserProvider } from './auth/user-context';
import { MyIonFavoriteProvider } from './MyIonFavorite';
import { MyIonMetricProvider } from './MyIonMetric';
import { MyIonQuizProvider } from './MyIonQuiz';
@@ -12,20 +13,22 @@ const ContextMeta = ({ children }: { children: React.ReactNode }) => {
return (
<>
<AppStateProvider>
<MyIonStoreProvider>
<MyIonFavoriteProvider>
<MyIonQuizProvider>
<MyIonMetricProvider>
<QueryClientProvider client={queryClient}>
<PocketBaseProvider>
{children}
{/* */}
</PocketBaseProvider>
</QueryClientProvider>
</MyIonMetricProvider>
</MyIonQuizProvider>
</MyIonFavoriteProvider>
</MyIonStoreProvider>
<UserProvider>
<MyIonStoreProvider>
<MyIonFavoriteProvider>
<MyIonQuizProvider>
<MyIonMetricProvider>
<QueryClientProvider client={queryClient}>
<PocketBaseProvider>
{children}
{/* */}
</PocketBaseProvider>
</QueryClientProvider>
</MyIonMetricProvider>
</MyIonQuizProvider>
</MyIonFavoriteProvider>
</MyIonStoreProvider>
</UserProvider>
</AppStateProvider>
</>
);

View File

@@ -7,10 +7,7 @@ export const useSignupFields = () => {
label: 'Name',
required: true,
input: {
props: {
type: 'text',
placeholder: 'Joe Bloggs',
},
props: { type: 'text', placeholder: 'Joe Bloggs' },
state: useFormInput(''),
},
},
@@ -19,10 +16,7 @@ export const useSignupFields = () => {
label: 'Email',
required: true,
input: {
props: {
type: 'email',
placeholder: 'joe@bloggs.com',
},
props: { type: 'email', placeholder: 'joe@bloggs.com' },
state: useFormInput(''),
},
},
@@ -31,10 +25,7 @@ export const useSignupFields = () => {
label: 'Password',
required: true,
input: {
props: {
type: 'password',
placeholder: '*********',
},
props: { type: 'password', placeholder: '*********' },
state: useFormInput(''),
},
},

View File

@@ -0,0 +1,11 @@
import type { RecordModel } from 'pocketbase';
import { COL_USER_METAS } from '../../constants';
import { pb } from '../../lib/pb';
export async function getUserMetaById(id: string): Promise<RecordModel> {
return pb.collection(COL_USER_METAS).getOne(id, {
expand: 'billingAddress',
requestKey: null,
});
}

View File

@@ -1,11 +1,12 @@
# GUIDELINES
This folder contains drivers for `User`/`Users` records using PocketBase:
This folder contains drivers for `UserMeta`/`UserMetas`(Collection ID: pbc_1305841361) records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- count (GetAllCount.tsx, GetActiveCount.tsx, GetBlockedCount.tsx, GetPendingCount.tsx)
- misc (Helloworld.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
@@ -15,15 +16,15 @@ the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-onlin
- 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 `@/db/Users/type.d.tsx`
- type information defined in `./type.d.tsx`
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_USERS } from '@/constants';
import { COL_CUSTOMERS } from '@/constants';
export async function createUser(data: CreateFormProps) {
export async function createCustomer(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
}

View File

@@ -0,0 +1,11 @@
`working directory`: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/UserMetas`
these files are clone from elsewhere,
please help to list `*.tsx.draft` files in `working directory` (e.g. `find`),
iterate the files listed in the result.
please understand, modify and update the content to handle `UserMeta` record thanks, modify comments/variables/paths/functions name please
restrict your modifications in working directory only,
I will handle all the modification outside this direcotry
e.g. if `lessonCategories` exist in file, modify it to `userMetas`

View File

@@ -0,0 +1,53 @@
import type { BillingAddress } from '@/components/dashboard/user_meta/type.d';
// DBUserMeta type definitions
export interface DBUserMeta {
name: string;
avatar: string;
email: string;
phone: string;
quota: number;
status: 'active' | 'blocked' | 'pending';
//
collectionId: string;
id: string;
createdAt: Date;
}
// 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,14 @@
import * as React from 'react';
import { UserContext } from '../contexts/auth/user-context';
import { UserContextValue } from '../contexts/auth/types';
export function useUser(): UserContextValue {
const context = React.useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
}

View File

@@ -95,7 +95,8 @@ i18n
// (tip move them in a JSON file and import them,
// or even better, manage them via a UI: https://react.i18next.com/guides/multiple-translation-files#manage-your-translations-with-a-management-gui)
resources,
lng: 'en', // if you're using a language detector, do not define the lng option
// if you're using a language detector, do not define the lng option
lng: 'en',
fallbackLng: 'en',
interpolation: {

View File

@@ -0,0 +1,116 @@
import { COL_USERS } from '../../../constants';
import { getUserMetaById } from '../../../db/UserMetas/GetById';
import { User } from '../../../types/user';
import { pb } from '../../pb';
function generateToken(): string {
const arr = new Uint8Array(12);
window.crypto.getRandomValues(arr);
return Array.from(arr, (v) => v.toString(16).padStart(2, '0')).join('');
}
// TODO: remove below as unused
// const user_xxx = {
// id: 'USR-000',
// avatar: '/assets/avatar.png',
// firstName: 'Sofia',
// lastName: 'Rivers',
// email: 'sofia@devias.io',
// } satisfies User;
export interface SignUpParams {
firstName: string;
lastName: string;
email: string;
password: string;
}
export interface SignInWithOAuthParams {
provider: 'google' | 'discord';
}
export interface SignInWithPasswordParams {
email: string;
password: string;
}
export interface ResetPasswordParams {
email: string;
}
class AuthClient {
async signUp(_: SignUpParams): Promise<{ error?: string }> {
// Make API request
// We do not handle the API, so we'll just generate a token and store it in localStorage.
const token = generateToken();
localStorage.setItem('custom-auth-token', token);
return {};
}
async signInWithOAuth(_: SignInWithOAuthParams): Promise<{ error?: string }> {
return { error: 'Social authentication not implemented' };
}
async signInWithPassword(params: SignInWithPasswordParams): Promise<{ error?: string }> {
const { email, password } = params;
try {
// Make API request
await pb.collection(COL_USERS).authWithPassword(email, password);
// // We do not handle the API, so we'll check if the credentials match with the hardcoded ones.
// if (email !== 'sofia@devias.io' || password !== 'Secret1') {
// return { error: 'Invalid credentials' };
// }
// const token = generateToken();
localStorage.setItem('custom-auth-token', pb.authStore.token);
return {};
} catch (error) {
// logger.error(error);
return { error: 'Invalid credentials' };
}
}
async resetPassword(_: ResetPasswordParams): Promise<{ error?: string }> {
return { error: 'Password reset not implemented' };
}
async updatePassword(_: ResetPasswordParams): Promise<{ error?: string }> {
return { error: 'Update reset not implemented' };
}
async getUser(): Promise<{ data?: User | null; error?: string }> {
// Make API request
// We do not handle the API, so just check if we have a token in localStorage.
// const token = localStorage.getItem('custom-auth-token');
// if (!token) {
// return { data: null };
// }
try {
// logger.debug(JSON.stringify(`getUser: ${pb.authStore.record?.id}`));
if (pb.authStore.record?.id !== undefined) {
const userMeta = await getUserMetaById(pb.authStore.record?.id);
// logger.debug({ userMeta });
return { data: userMeta as unknown as User };
}
return { data: null };
} catch (error) {
return { error: 'sorry cannot get user meta' };
}
}
async signOut(): Promise<{ error?: string }> {
pb.authStore.clear();
localStorage.removeItem('custom-auth-token');
return {};
}
}
export const authClient = new AuthClient();

View File

@@ -0,0 +1,5 @@
import { DBUserMeta } from '../db/UserMetas/type';
export function getStudentAvatar(studentMeta: DBUserMeta) {
return `url(http://localhost:8090/api/files/${studentMeta.collectionId}/${studentMeta.id}/${studentMeta.avatar})`;
}

View File

@@ -0,0 +1,5 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
export { pb };

View File

@@ -35,7 +35,7 @@ const LessonContainer: React.FC<ContainerProps> = ({ lesson_type_id: lesson_type
if (loading) return <LoadingScreen />;
if (!selected_content) return <LoadingScreen />;
if (selected_content.length == 0) return <>loading</>;
if (selected_content.length == 0) return <LoadingScreen />;
return (
<>

View File

@@ -0,0 +1,74 @@
import {
IonBackButton,
IonButton,
IonButtons,
IonCardTitle,
IonCol,
IonContent,
IonFooter,
IonGrid,
IonHeader,
IonIcon,
IonInput,
IonLabel,
IonPage,
IonRow,
IonText,
IonToolbar,
useIonRouter,
} from '@ionic/react';
import styles from './style.module.scss';
import _ from 'lodash';
import { Router, useParams } from 'react-router';
import { Wave } from '../../../components/Wave';
import { Paths } from '../../../Paths';
import { useTransition } from 'react';
import { useTranslation } from 'react-i18next';
import { useUser } from '../../../hooks/use-user';
function AuthorizedTest(): React.JSX.Element {
const router = useIonRouter();
const { t } = useTranslation();
const { user } = useUser();
function handleBackToLogin() {
router.push(Paths.AuthLogin);
}
function handleViewStudentInfoOnClick() {
if (user?.id) {
router.push(Paths.GetStudentInfoLink(user.id));
}
}
return (
<IonPage className={styles.loginPage}>
<IonHeader>{/* */}</IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonCol>
<IonRow>Authorized page test</IonRow>
{JSON.stringify({ user })}
{/* */}
<IonRow>
<IonButton onClick={handleViewStudentInfoOnClick}>{t('view-student-info')}</IonButton>
</IonRow>
{/* */}
<IonRow>
<IonButton onClick={handleBackToLogin}>Back to login</IonButton>
</IonRow>
</IonCol>
</IonGrid>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Wave />
</IonGrid>
</IonFooter>
</IonPage>
);
}
export default AuthorizedTest;

View File

@@ -0,0 +1,17 @@
.loginPage {
ion-toolbar {
--border-style: none;
--border-color: transparent;
--padding-top: 1rem;
--padding-bottom: 1rem;
--padding-start: 1rem;
--padding-end: 1rem;
}
}
.headingText {
h5 {
margin-top: 0.2rem;
// color: #d3a6c7;
}
}

View File

@@ -0,0 +1,107 @@
import {
IonButton,
IonCardTitle,
IonCol,
IonContent,
IonFooter,
IonGrid,
IonHeader,
IonImg,
IonPage,
IonRouterLink,
IonRow,
IonToolbar,
useIonRouter,
} from '@ionic/react';
// import { Action } from '../components/Action';
import styles from './style.module.scss';
import { Action } from '../../../components/Action';
import { Paths } from '../../../Paths';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { useUser } from '../../../hooks/use-user';
import { LoadingScreen } from '../../../components/LoadingScreen';
const AuthHome = () => {
const { t } = useTranslation();
const { user, checkSession, isLoading } = useUser();
const router = useIonRouter();
const [showLoading, setShowLoading] = useState<boolean>(true);
const [showError, setShowErrr] = useState<{ show: boolean; message: string }>({
show: false,
message: '',
});
const [checkingSession, setCheckingSession] = useState<boolean>(true);
useEffect(() => {
if (!checkingSession) {
if (!user) {
router.push(Paths.AuthLogin);
} else {
router.push(Paths.AuthorizedTest);
}
setShowLoading(false);
}
}, [user, checkingSession]);
useEffect(() => {
checkSession?.()
.then(() => {
setCheckingSession(false);
})
.catch((err) => console.error(err));
}, [checkSession]);
if (showLoading) return <LoadingScreen />;
// if (showError) return <>{showError.message}</>;
return (
<IonPage className={'styles.homePage'}>
<IonHeader>
{/* <IonToolbar className="ion-no-margin ion-no-padding"> */}
<IonImg src="/assets/login2.jpeg" />
{/* </IonToolbar> */}
</IonHeader>
<IonContent fullscreen>
<div className={styles.getStarted}>
<IonGrid>
<IonRow className={`ion-text-center ion-justify-content-center ${styles.heading}`}>
<IonCol size="11" className={styles.headingText}>
<IonCardTitle>
{/* */}
Join millions of other people discovering their creative side
</IonCardTitle>
</IonCol>
</IonRow>
<IonRow className={`ion-text-center ion-justify-content-center`}>
<IonRouterLink routerLink={Paths.AuthSignUp} className="custom-link">
<IonCol size="11">
<IonButton className={`${styles.getStartedButton} custom-button`}>
{/* */}
Get started &rarr;
</IonButton>
</IonCol>
</IonRouterLink>
</IonRow>
</IonGrid>
</div>
</IonContent>
<IonFooter>
<IonGrid style={{ marginBottom: '1rem' }}>
<Action
message={t('already-got-an-account')}
text={t('login')}
link={Paths.AuthLogin}
//
/>
</IonGrid>
</IonFooter>
</IonPage>
);
};
export default AuthHome;

View File

@@ -11,12 +11,33 @@ import {
IonRouterLink,
IonRow,
IonToolbar,
useIonRouter,
} from '@ionic/react';
// import { Action } from '../components/Action';
import styles from './style.module.scss';
import { Action } from '../../../components/Action';
import { Paths } from '../../../Paths';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { useUser } from '../../../hooks/use-user';
import { LoadingScreen } from '../../../components/LoadingScreen';
const AuthHome = () => {
const { t } = useTranslation();
const [showLoading, setShowLoading] = useState<boolean>(true);
const [showError, setShowErrr] = useState<{ show: boolean; message: string }>({
show: false,
message: '',
});
useEffect(() => {
setShowLoading(false);
}, []);
if (showLoading) return <LoadingScreen />;
if (showError.show) return <>{showError.message}</>;
return (
<IonPage className={'styles.homePage'}>
<IonHeader>
@@ -29,14 +50,20 @@ const AuthHome = () => {
<IonGrid>
<IonRow className={`ion-text-center ion-justify-content-center ${styles.heading}`}>
<IonCol size="11" className={styles.headingText}>
<IonCardTitle>Join millions of other people discovering their creative side</IonCardTitle>
<IonCardTitle>
{/* */}
Join millions of other people discovering their creative side
</IonCardTitle>
</IonCol>
</IonRow>
<IonRow className={`ion-text-center ion-justify-content-center`}>
<IonRouterLink routerLink="/signup" className="custom-link">
<IonRouterLink routerLink={Paths.AuthSignUp} className="custom-link">
<IonCol size="11">
<IonButton className={`${styles.getStartedButton} custom-button`}>Get started &rarr;</IonButton>
<IonButton className={`${styles.getStartedButton} custom-button`}>
{/* */}
Get started &rarr;
</IonButton>
</IonCol>
</IonRouterLink>
</IonRow>
@@ -45,8 +72,13 @@ const AuthHome = () => {
</IonContent>
<IonFooter>
<IonGrid>
<Action message="Already got an account?" text="Login" link="/login" />
<IonGrid style={{ marginBottom: '1rem' }}>
<Action
message={t('already-got-an-account')}
text={t('login')}
link={Paths.AuthLogin}
//
/>
</IonGrid>
</IonFooter>
</IonPage>

View File

@@ -12,77 +12,197 @@ import {
IonImg,
IonInput,
IonInputPasswordToggle,
IonItem,
IonLabel,
IonList,
IonPage,
IonRouterLink,
IonRow,
IonText,
IonToolbar,
useIonRouter,
} from '@ionic/react';
import styles from './style.module.scss';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { orderBy, chunk, concat } from 'lodash';
import _ from 'lodash';
import { arrowBack, shapesOutline } from 'ionicons/icons';
import { arrowBack, eye, lockClosed, shapesOutline } from 'ionicons/icons';
import { CustomField } from '../../../components/CustomField';
import { useLoginFields } from '../../../data/fields';
import { Action } from '../../../components/Action';
import { Wave } from '../../../components/Wave';
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Paths } from '../../../Paths';
import { z as zod } from 'zod';
import { pb } from '../../../lib/pb';
import { ClientResponseError } from 'pocketbase';
import { COL_USER_METAS, COL_USERS } from '../../../constants';
import { authClient } from '../../../lib/auth/custom/client';
import { useUser } from '../../../hooks/use-user';
function AuthLogin(): React.JSX.Element {
const params = useParams();
const [errors, setErrors] = useState(false);
const router = useIonRouter();
const [fieldErrors, setFieldErrors] = useState(false);
const { t } = useTranslation();
const [isPending, setIsPending] = React.useState<boolean>(false);
const [password, setPassword] = useState<string | number | null | undefined>('');
const schema = zod.object({
email: zod.string(),
password: zod.string(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
email: '',
password: '',
//
} satisfies Values;
const login = () => {};
const { checkSession } = useUser();
const {
control,
handleSubmit,
setError,
formState: { errors },
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
console.log({ values });
try {
await authClient.signInWithPassword({
email: values.email,
password: values.password,
//
});
// Refresh the auth state
await checkSession?.();
// console.log(pb.authStore.record.id);
// UserProvider, for this case, will not refresh the router
// After refresh, GuestGuard will handle the redirect
router.push(Paths.StudentMenu);
} catch (err: any) {
const res_err = err as unknown as ClientResponseError;
const {
response: { message },
} = res_err;
console.error({ message });
setError('root', { message }, { shouldFocus: true });
}
},
[router, setError]
);
return (
<IonPage className={styles.loginPage}>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton icon={arrowBack} text="" className="custom-back" />
</IonButtons>
<IonButtons slot="end">
<IonButton className="custom-button">
<IonIcon icon={shapesOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonHeader></IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonRow>
<IonCol size="12" className={styles.headingText}>
<IonCardTitle>Log in</IonCardTitle>
<h5>Welcome back, hope you're doing well</h5>
</IonCol>
</IonRow>
<form onSubmit={handleSubmit(onSubmit)}>
<IonGrid className="ion-padding">
<IonRow>
<IonCol size="12" className={styles.headingText}>
<IonCardTitle>
{/* */}
{t('login')}
</IonCardTitle>
<h5>Welcome back, hope you're doing well</h5>
</IonCol>
</IonRow>
<IonRow className="ion-margin-top ion-padding-top">
<IonCol size="12">
<IonInput labelPlacement="floating" value="hi@ionic.io">
<div slot="label">
Email <IonText color="danger">(Required)</IonText>
<IonRow className="ion-margin-top ion-padding-top">
<IonCol size="12">
<div>
<Controller
control={control}
name="email"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'email'}</IonLabel>
<IonInput
type="email"
placeholder="e.g. user5@123.com / user5@123.com"
onIonInput={(e) => field.onChange(e.detail.value)}
onIonBlur={() => field.onBlur()}
/>
{errors.email ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.email.message}</IonText>
</IonText>
) : null}
</>
)}
/>
</div>
</IonInput>
<IonInput type="password" label="Password" value="NeverGonnaGiveYouUp">
<IonInputPasswordToggle slot="end"></IonInputPasswordToggle>
</IonInput>
<IonButton className="custom-button" expand="block" onClick={login}>
Login
</IonButton>
</IonCol>
</IonRow>
</IonGrid>
<div style={{ marginTop: '3rem' }}>
<Controller
control={control}
name="password"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'password'}</IonLabel>
<IonInput
type="password"
onIonInput={(e) => field.onChange(e.detail.value)}
onIonBlur={() => field.onBlur()}
/>
{errors.password ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.password.message}</IonText>
</IonText>
) : null}
</>
)}
/>
</div>
<IonButton
type="submit"
className="custom-button"
expand="block"
// onClick={createAccount}
>
{t('login')}
</IonButton>
<div>
{errors.root ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.root.message}</IonText>
</IonText>
) : null}
</div>
</IonCol>
</IonRow>
</IonGrid>
</form>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Action message="Don't have an account?" text="Sign up" link="/auth/signup" />
<Action
message={t('dont-have-an-account')}
text={t('sign-up')}
link={Paths.AuthSignUp}
//
/>
<Wave />
</IonGrid>
</IonFooter>

View File

@@ -9,82 +9,228 @@ import {
IonGrid,
IonHeader,
IonIcon,
IonImg,
IonInput,
IonInputPasswordToggle,
IonLabel,
IonPage,
IonRouterLink,
IonRow,
IonText,
IonToolbar,
useIonRouter,
} from '@ionic/react';
import styles from './style.module.scss';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { orderBy, chunk, concat } from 'lodash';
import _ from 'lodash';
import { arrowBack, shapesOutline } from 'ionicons/icons';
import { CustomField } from '../../../components/CustomField';
import { useLoginFields, useSignupFields } from '../../../data/fields';
import { useSignupFields } from '../../../data/fields';
import { Action } from '../../../components/Action';
import { Wave } from '../../../components/Wave';
import { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useFormInput } from '../../../data/utils';
import { z as zod } from 'zod';
import { pb } from '../../../lib/pb';
import { ClientResponseError } from 'pocketbase';
import { COL_USER_METAS, COL_USERS } from '../../../constants';
import { Paths } from '../../../Paths';
function AuthSignUp(): React.JSX.Element {
const params = useParams();
const fields = useSignupFields();
const [errors, setErrors] = useState(false);
const router = useIonRouter();
const [fieldErrors, setFieldErrors] = useState(false);
const { t } = useTranslation();
const [isPending, setIsPending] = React.useState<boolean>(false);
const schema = zod.object({
name: zod.string().min(3, t('name-too-short')),
email: zod.string(),
password: zod.string().min(8, t('password-should-be-at-least-8-characters')),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
name: 'new user',
email: 'test@123.com',
password: 'Aa1234567',
//
} satisfies Values;
const login = () => {};
function createAccount() {}
const {
control,
handleSubmit,
setError,
formState: { errors },
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
// setIsPending(true);
const user = {
password: values.password,
passwordConfirm: values.password,
email: values.email,
emailVisibility: true,
// verified: true,
name: values.name,
visible: '',
phone: '',
};
try {
const userRecord = await pb.collection(COL_USERS).create(user);
const userMeta = {
address: '',
meta: {},
user_id: userRecord.id,
state: '',
role: 'student',
name: values.name,
email: values.email,
phone: '',
company: '',
taxId: '',
timezone: '',
language: '',
currency: '',
billingAddress: [],
};
const userMetaRecord = await pb.collection(COL_USER_METAS).create(userMeta);
await pb.collection('users').requestVerification(user.email);
router.push(Paths.SignUpSuccess);
} catch (err: any) {
const res_err = err as unknown as ClientResponseError;
const {
originalError: {
data: { data },
},
} = res_err;
if (data?.email) {
const {
email: { code },
} = data;
console.log({ code });
if (code == 'validation_not_unique') {
setError('email', { message: t('email-is-not-unique') }, { shouldFocus: true });
}
}
}
},
[router, setError]
);
return (
<IonPage className={styles.loginPage}>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton icon={arrowBack} text="" className="custom-back" />
</IonButtons>
<IonButtons slot="end">
<IonButton className="custom-button">
<IonIcon icon={shapesOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonHeader></IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonRow>
<IonCol size="12" className={styles.headingText}>
<IonCardTitle>Sign up</IonCardTitle>
<h5>Lets get to know each other</h5>
</IonCol>
</IonRow>
<form onSubmit={handleSubmit(onSubmit)}>
<IonGrid className="ion-padding">
<IonRow>
<IonCol size="12" className={styles.headingText}>
<IonCardTitle>
{/* */}
{t('sign-up')}
</IonCardTitle>
<h5>
{/* */}
{t('lets-get-to-know-each-other')}
</h5>
</IonCol>
</IonRow>
<IonRow className="ion-margin-top ion-padding-top">
<IonCol size="12">
{fields.map((field, i) => {
return (
<div key={i}>
<CustomField field={field} errors={errors} />
</div>
);
})}
<IonRow className="ion-margin-top ion-padding-top">
<IonCol size="12">
<div>
<Controller
control={control}
name="name"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>
{'name'}
{/* {error && <p className="animate__animated animate__bounceIn">{errorMessage}</p>} */}
</IonLabel>
<IonInput {...field} type="text" />
</>
)}
/>
</div>
<IonButton className="custom-button" expand="block" onClick={createAccount}>
Create account
</IonButton>
</IonCol>
</IonRow>
</IonGrid>
<div>
<Controller
control={control}
name="email"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'email'}</IonLabel>
<IonInput {...field} type="email" />
{errors.email ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.email.message}</IonText>
</IonText>
) : null}
</>
)}
/>
</div>
<div>
<Controller
control={control}
name="password"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'password'}</IonLabel>
<IonInput {...field} type="password" />
{errors.password ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.password.message}</IonText>
</IonText>
) : null}
</>
)}
/>
</div>
<IonButton
type="submit"
className="custom-button"
expand="block"
// onClick={createAccount}
>
{t('create-account')}
</IonButton>
</IonCol>
</IonRow>
</IonGrid>
</form>
{_.isEmpty(errors) ? null : JSON.stringify(errors)}
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Action message="Already got an account?" text="Login" link="/auth/login" />
<Action
message="Already got an account?"
text="Login"
link="/auth/login"
//
/>
<Wave />
</IonGrid>
</IonFooter>

View File

@@ -0,0 +1,61 @@
import {
IonBackButton,
IonButton,
IonButtons,
IonCardTitle,
IonCol,
IonContent,
IonFooter,
IonGrid,
IonHeader,
IonIcon,
IonInput,
IonLabel,
IonPage,
IonRow,
IonText,
IonToolbar,
useIonRouter,
} from '@ionic/react';
import styles from './style.module.scss';
import _ from 'lodash';
import { Router, useParams } from 'react-router';
import { Wave } from '../../../components/Wave';
import { Paths } from '../../../Paths';
function SignUpSuccess(): React.JSX.Element {
const router = useIonRouter();
function handleBackToLogin() {
router.push(Paths.AuthLogin);
}
return (
<IonPage className={styles.loginPage}>
<IonHeader>
{/* */}
{/* */}
</IonHeader>
<IonContent fullscreen>
<IonGrid className="ion-padding">
{/* */}
{/* */}
<IonCol>
<IonRow>SignUp Success</IonRow>
<IonRow>
<IonButton onClick={handleBackToLogin}>Back to login</IonButton>
</IonRow>
{/* */}
</IonCol>
</IonGrid>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Wave />
</IonGrid>
</IonFooter>
</IonPage>
);
}
export default SignUpSuccess;

View File

@@ -0,0 +1,17 @@
.loginPage {
ion-toolbar {
--border-style: none;
--border-color: transparent;
--padding-top: 1rem;
--padding-bottom: 1rem;
--padding-start: 1rem;
--padding-end: 1rem;
}
}
.headingText {
h5 {
margin-top: 0.2rem;
// color: #d3a6c7;
}
}

View File

@@ -0,0 +1,154 @@
import {
IonButton,
IonCol,
IonContent,
IonFooter,
IonGrid,
IonHeader,
IonPage,
IonRow,
IonText,
useIonRouter,
} from '@ionic/react';
import styles from './style.module.scss';
import { useParams } from 'react-router';
import { Wave } from '../../../components/Wave';
import { Paths } from '../../../Paths';
import { useEffect, useState } from 'react';
import { getUserMetaById } from '../../../db/UserMetas/GetById';
import { useTranslation } from 'react-i18next';
import { DBUserMeta } from '../../../db/UserMetas/type';
import { LoadingScreen } from '../../../components/LoadingScreen';
import { getStudentAvatar } from '../../../lib/getStudentAvatar';
import { authClient } from '../../../lib/auth/custom/client';
import { useUser } from '../../../hooks/use-user';
function StudentInfo(): React.JSX.Element {
const router = useIonRouter();
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const [studentMeta, setStudentMeta] = useState<DBUserMeta>();
const test = useUser();
const [showLoading, setShowLoading] = useState<boolean>(true);
const [showError, setShowError] = useState<{ show: boolean; message: string }>({ show: false, message: '' });
function handleBackToLogin() {
router.push(Paths.AuthLogin);
}
function handleBackOnClick() {
router.push(Paths.Setting);
}
async function handleFetchUserMeta() {
try {
const result = await getUserMetaById(id);
const tempStudentMeta = result as unknown as DBUserMeta;
setStudentMeta(tempStudentMeta);
setShowLoading(false);
} catch (error) {
setShowError({ show: true, message: JSON.stringify({ error }, null, 2) });
setShowLoading(false);
}
}
async function handleLogoutOnClick() {
try {
await authClient.signOut();
router.push(Paths.AuthLogin);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
void handleFetchUserMeta();
}, []);
if (showLoading) return <LoadingScreen />;
if (!studentMeta) return <LoadingScreen />;
if (showError.show) return <>{showError.message}</>;
return (
<IonPage className={styles.loginPage}>
<IonHeader>{/* */}</IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonRow className="ion-justify-content-center">
<IonCol size={'3'}>
<div
style={{
backgroundImage: getStudentAvatar(studentMeta),
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
//
width: '25vw',
height: '25vw',
//
borderRadius: 'calc( 25vw / 2 )',
}}
></div>
</IonCol>
</IonRow>
{/* */}
<IonRow
className="ion-justify-content-between"
style={{
marginTop: '1rem',
marginBottom: '1rem',
//
}}
>
<IonText>{t('student-name')}</IonText>
<IonText>{studentMeta.name}</IonText>
</IonRow>
{/* */}
<IonRow
className="ion-justify-content-between"
style={{
marginTop: '1rem',
marginBottom: '1rem',
//
}}
>
<IonText>{t('student-email')}</IonText>
<IonText>{studentMeta.email}</IonText>
</IonRow>
{/* */}
<IonRow
className="ion-justify-content-between"
style={{
marginTop: '1rem',
marginBottom: '1rem',
//
}}
>
<IonText>{t('student-phone')}</IonText>
<IonText>{studentMeta.phone}</IonText>
</IonRow>
{/* */}
<IonRow className="ion-justify-content-center">
<IonButton onClick={handleBackOnClick}>{t('back')}</IonButton>
</IonRow>
<IonRow className="ion-justify-content-center">
<IonButton onClick={handleLogoutOnClick}>{t('logout')}</IonButton>
</IonRow>
</IonGrid>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Wave />
</IonGrid>
</IonFooter>
</IonPage>
);
}
export default StudentInfo;

View File

@@ -0,0 +1,17 @@
.signupPage {
ion-toolbar {
--border-style: none;
--border-color: transparent;
--padding-top: 1rem;
--padding-bottom: 1rem;
--padding-start: 1rem;
--padding-end: 1rem;
}
}
.headingText {
h5 {
margin-top: 0.2rem;
// color: #d3a6c7;
}
}

View File

@@ -0,0 +1,71 @@
import {
IonBackButton,
IonButton,
IonButtons,
IonCardTitle,
IonCol,
IonContent,
IonFooter,
IonGrid,
IonHeader,
IonIcon,
IonInput,
IonLabel,
IonPage,
IonRow,
IonText,
IonToolbar,
useIonRouter,
} from '@ionic/react';
import styles from './style.module.scss';
import _ from 'lodash';
import { Router, useParams } from 'react-router';
import { Wave } from '../../../components/Wave';
import { Paths } from '../../../Paths';
import { useTransition } from 'react';
import { useTranslation } from 'react-i18next';
import { useUser } from '../../../hooks/use-user';
function StudentMenu(): React.JSX.Element {
const router = useIonRouter();
const { t } = useTranslation();
const { user } = useUser();
function handleBackToLogin() {
router.push(Paths.AuthLogin);
}
function handleViewStudentInfoOnClick() {
if (user?.id) {
router.push(Paths.GetStudentInfoLink(user.id));
}
}
return (
<IonPage className={styles.loginPage}>
<IonHeader>{/* */}</IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonRow>Student Menu</IonRow>
{/* */}
<IonRow>
<IonButton onClick={handleViewStudentInfoOnClick}>{t('view-student-info')}</IonButton>
</IonRow>
{/* */}
<IonRow>
<IonButton onClick={handleBackToLogin}>Back to login</IonButton>
</IonRow>
</IonGrid>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Wave />
</IonGrid>
</IonFooter>
</IonPage>
);
}
export default StudentMenu;

View File

@@ -0,0 +1,17 @@
.loginPage {
ion-toolbar {
--border-style: none;
--border-color: transparent;
--padding-top: 1rem;
--padding-bottom: 1rem;
--padding-start: 1rem;
--padding-end: 1rem;
}
}
.headingText {
h5 {
margin-top: 0.2rem;
// color: #d3a6c7;
}
}

View File

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