From 712041b0e51ecb3c17b9001375f92a75695b85e7 Mon Sep 17 00:00:00 2001 From: ammarmelade Date: Thu, 25 Apr 2024 18:38:02 +0700 Subject: [PATCH 1/5] Initial commit --- .env.development | 2 +- components/DefautLayout.tsx | 73 +------ data/RegisteredEmails.ts | 5 + functions/BackendApiClient.ts | 375 ++++++++++++++++++++++++++++++++++ package-lock.json | 25 +++ package.json | 1 + pages/_app.tsx | 17 +- pages/index.tsx | 249 ++++++++++++++++++++-- pages/login.tsx | 55 +++++ pages/orders/[id].tsx | 42 ++++ pages/register.tsx | 153 ++++++++++++++ types/OrderData.ts | 8 + 12 files changed, 914 insertions(+), 91 deletions(-) create mode 100644 data/RegisteredEmails.ts create mode 100644 functions/BackendApiClient.ts create mode 100644 pages/login.tsx create mode 100644 pages/orders/[id].tsx create mode 100644 pages/register.tsx create mode 100644 types/OrderData.ts diff --git a/.env.development b/.env.development index 58ed8bd..5fdce69 100644 --- a/.env.development +++ b/.env.development @@ -1,6 +1,6 @@ NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=e01b7895a403fa7364061b2f01a650fc -BACKEND_API_HOST=https://demo.duendesoftware.com +BACKEND_API_HOST=https://new-dev.accelist.com:1234 OIDC_ISSUER=https://demo.duendesoftware.com OIDC_CLIENT_ID=interactive.public.short OIDC_SCOPE=openid profile email api offline_access diff --git a/components/DefautLayout.tsx b/components/DefautLayout.tsx index 973c396..b964063 100644 --- a/components/DefautLayout.tsx +++ b/components/DefautLayout.tsx @@ -29,80 +29,11 @@ const DefaultLayout: React.FC<{ menu.push({ key: '/', - label: 'Home', + label: 'Main menu', icon: , onClick: () => router.push('/') }); - menu.push( - { - key: '#menu-1', - label: 'Menu 1', - icon: , - children: [ - { - key: '/dashboard', - label: 'Dashboard', - onClick: () => router.push('/dashboard') - }, - { - key: '/sub-menu-b', - label: 'Sub Menu B', - onClick: () => router.push('/') - }, - { - key: '/sub-menu-c', - label: 'Sub Menu C', - onClick: () => router.push('/') - } - ] - }, - { - key: '#menu-2', - label: 'Menu 2', - icon: , - children: [ - { - key: '/sub-menu-d', - label: 'Sub Menu D', - onClick: () => router.push('/') - }, - { - key: '/sub-menu-e', - label: 'Sub Menu E', - onClick: () => router.push('/') - }, - { - key: '/sub-menu-f', - label: 'Sub Menu F', - onClick: () => router.push('/') - } - ] - }, - { - key: '#menu-3', - label: 'Menu 3', - icon: , - children: [ - { - key: '/sub-menu-g', - label: 'Sub Menu G', - onClick: () => router.push('/') - }, - { - key: '/sub-menu-h', - label: 'Sub Menu H', - onClick: () => router.push('/') - }, - { - key: '/sub-menu-i', - label: 'Sub Menu I', - onClick: () => router.push('/') - } - ] - } - ); - if (status === 'authenticated') { menu.push({ key: '/sign-out', @@ -119,7 +50,7 @@ const DefaultLayout: React.FC<{ }); } else { menu.push({ - key: '/sign-in', + key: '/login', label: 'Sign in', icon: , onClick: () => { diff --git a/data/RegisteredEmails.ts b/data/RegisteredEmails.ts new file mode 100644 index 0000000..7fdac18 --- /dev/null +++ b/data/RegisteredEmails.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; + +const registeredEmailsAtom = atom([]); + +export default registeredEmailsAtom; \ No newline at end of file diff --git a/functions/BackendApiClient.ts b/functions/BackendApiClient.ts new file mode 100644 index 0000000..baf5495 --- /dev/null +++ b/functions/BackendApiClient.ts @@ -0,0 +1,375 @@ +//---------------------- +// +// Generated using the NSwag toolchain v14.0.7.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +/* tslint:disable */ +/* eslint-disable */ +// ReSharper disable InconsistentNaming + +export class AuthClient { + private http: { fetch(url: RequestInfo, init?: RequestInit): Promise }; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) { + this.http = http ? http : window as any; + this.baseUrl = baseUrl ?? ""; + } + + /** + * @param body (optional) + * @return Success + */ + login(body: LoginModel | undefined): Promise { + let url_ = this.baseUrl + "/api/v1/Auth/Login"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processLogin(_response); + }); + } + + protected processLogin(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + return; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @param body (optional) + * @return Success + */ + register(body: RegisterModel | undefined): Promise { + let url_ = this.baseUrl + "/api/v1/Auth/Register"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processRegister(_response); + }); + } + + protected processRegister(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + return; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } +} + +export class OrderClient { + private http: { fetch(url: RequestInfo, init?: RequestInit): Promise }; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) { + this.http = http ? http : window as any; + this.baseUrl = baseUrl ?? ""; + } + + /** + * @param body (optional) + * @return Success + */ + orderGrid(body: OrderFilter | undefined): Promise { + let url_ = this.baseUrl + "/api/v1/Order/OrderGrid"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processOrderGrid(_response); + }); + } + + protected processOrderGrid(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + return; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return Success + */ + orderDetail(id: number): Promise { + let url_ = this.baseUrl + "/api/v1/Order/OrderDetail/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processOrderDetail(_response); + }); + } + + protected processOrderDetail(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + return; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @param body (optional) + * @return Success + */ + createOrder(body: CreateOrderModel | undefined): Promise { + let url_ = this.baseUrl + "/api/v1/Order/CreateOrder"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processCreateOrder(_response); + }); + } + + protected processCreateOrder(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + return; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @param body (optional) + * @return Success + */ + updateOrder(body: UpdateOrderModel | undefined): Promise { + let url_ = this.baseUrl + "/api/v1/Order/UpdateOrder"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processUpdateOrder(_response); + }); + } + + protected processUpdateOrder(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + return; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return Success + */ + deleteOrder(id: number): Promise { + let url_ = this.baseUrl + "/api/v1/Order/DeleteOrder/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processDeleteOrder(_response); + }); + } + + protected processDeleteOrder(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + return; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } +} + +export interface CreateOrderModel { + description?: string | undefined; + orderFrom?: string | undefined; + orderTo?: string | undefined; + quantity?: number; + orderedAt?: Date; +} + +export interface LoginModel { + email?: string | undefined; + password: string; + phoneNumber?: string | undefined; +} + +export interface OrderFilter { + orderId?: number | undefined; + orderFrom?: string | undefined; + orderTo?: string | undefined; + quantity?: number | undefined; + orderedAt?: Date | undefined; + currentPage?: number; + pageSize?: number; +} + +export interface RegisterModel { + email?: string | undefined; + dateOfBirth?: Date; + gender?: string | undefined; + address?: string | undefined; + username?: string | undefined; + password?: string | undefined; +} + +export interface UpdateOrderModel { + description?: string | undefined; + orderFrom?: string | undefined; + orderTo?: string | undefined; + quantity?: number; +} + +interface OrderData { + orderId: number, + orderFrom: string, + orderTo: string, + total: number, + quantity: number, + orderedAt: string, +} + +type OrderGridResponse = void | OrderData[] | undefined; + +export class ApiException extends Error { + override message: string; + status: number; + response: string; + headers: { [key: string]: any; }; + result: any; + + constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) { + super(); + + this.message = message; + this.status = status; + this.response = response; + this.headers = headers; + this.result = result; + } + + protected isApiException = true; + + static isApiException(obj: any): obj is ApiException { + return obj.isApiException === true; + } +} + +function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): any { + if (result !== null && result !== undefined) + throw result; + else + throw new ApiException(message, status, response, headers, null); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9344003..4d88b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fortawesome/react-fontawesome": "0.2.0", "@hookform/error-message": "2.0.1", "@hookform/resolvers": "3.0.1", + "@tanstack/react-query": "5.29.2", "antd": "5.4.0", "dayjs": "1.11.7", "http-proxy": "1.18.1", @@ -671,6 +672,30 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.29.0.tgz", + "integrity": "sha512-WgPTRs58hm9CMzEr5jpISe8HXa3qKQ8CxewdYZeVnA54JrPY9B1CZiwsCoLpLkf0dGRZq+LcX5OiJb0bEsOFww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.29.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.29.2.tgz", + "integrity": "sha512-nyuWILR4u7H5moLGSiifLh8kIqQDLNOHGuSz0rcp+J75fNc8aQLyr5+I2JCHU3n+nJrTTW1ssgAD8HiKD7IFBQ==", + "dependencies": { + "@tanstack/query-core": "5.29.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@types/http-proxy": { "version": "1.17.10", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.10.tgz", diff --git a/package.json b/package.json index 3d75a9a..b1296cd 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@fortawesome/react-fontawesome": "0.2.0", "@hookform/error-message": "2.0.1", "@hookform/resolvers": "3.0.1", + "@tanstack/react-query": "5.29.2", "antd": "5.4.0", "dayjs": "1.11.7", "http-proxy": "1.18.1", diff --git a/pages/_app.tsx b/pages/_app.tsx index 57444b8..9ca643d 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -10,6 +10,7 @@ import { SessionErrorHandler } from '../components/SessionErrorHandler'; // https://fontawesome.com/v5/docs/web/use-with/react#next-js import { config } from '@fortawesome/fontawesome-svg-core'; import '../styles/globals.css'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; config.autoAddCss = false; type NextPageWithLayout = NextPage & { @@ -22,6 +23,8 @@ type AppPropsWithLayout = AppProps<{ Component: NextPageWithLayout; } +const queryClient = new QueryClient(); + function CustomApp({ Component, pageProps: { session, ...pageProps } @@ -30,12 +33,14 @@ function CustomApp({ const withLayout = Component.layout ?? (page => page); return ( // https://next-auth.js.org/getting-started/client#sessionprovider - - - {withLayout()} - - + + + + {withLayout()} + + + ); } diff --git a/pages/index.tsx b/pages/index.tsx index 6c0943a..53c2464 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,15 +1,238 @@ -import { WithDefaultLayout } from '../components/DefautLayout'; -import { Title } from '../components/Title'; -import { Page } from '../types/Page'; - -const IndexPage: Page = () => { - return ( -
- Home - Hello World! -
- ); +import { WithDefaultLayout } from "@/components/DefautLayout" +import { Page } from "@/types/Page" +import { useEffect, useState } from "react" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEye, faPenToSquare, faSort, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { OrderData } from "@/types/OrderData"; + +function OrderTable({ rowNumber, rowData, currentPage }) { + + return <> + + + + + + + {((currentPage - 1) * 5)+ rowNumber + 1} + + + + {rowData.orderFrom} + + + + {rowData.orderTo} + + + + {rowData.quantity} + + + + {rowData.total} + + + + {rowData.orderedAt} + + + + + + + + + + + + + + + + + + +} + +const LoginPage: Page = () => { + + const [currentPage, setCurrentPage] = useState(1); + + const [sortAscending, setSortAscending] = useState(false); + + const pages = [1, 2, 3, 4, 5]; + + const [orderList, setOrderList] = useState([]) + + async function fetchData() { + + const requestData = { + currentPage: currentPage, + pageSize: 25, + }; + + const reqInit: RequestInit = { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(requestData), + } + + try { + const response = await fetch('http://localhost:3000/api/be/api/v1/Order/OrderGrid', reqInit); + const responseData = await response.json(); + return responseData; + } catch (error) { + console.error(error); + } + } + + useEffect(() => { + const fetchAndSetData = async () => { + const data = await fetchData(); + setOrderList(data); + }; + + fetchAndSetData(); + }, []); + + // const { data } = useQuery( + // { + // queryKey: ['orders'], + // queryFn: async () => await fetchData() + // } + // ); + + function sortOrdersByFrom(property: keyof OrderData) { + + setSortAscending(!sortAscending); + + let modifier = 1; + + if (sortAscending) { + modifier = 1; + } else { + modifier = -1; + } + + const temp: OrderData[] | undefined = orderList?.slice(0); + + const sortedByOrderFrom = temp?.sort((n1, n2) => { + if (n1[property] > n2[property]) { + return modifier * 1; + } + + if (n1[property] < n2[property]) { + return modifier * -1; + } + + return 0; + }); + setOrderList(sortedByOrderFrom); + } + + return <> + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {orderList && orderList.slice(((currentPage - 1) * 5), ((currentPage - 1) * 5) + 5).map((item, index) => ( + + ))} + +
+ No. + +
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ Edit +
+ +
+ +
+
    +
  • + +
  • + + {pages.map((item) => ( + <> +
  • + {item !== currentPage ? + : + } +
  • + + ))} + +
  • + +
  • +
+
+ + } -IndexPage.layout = WithDefaultLayout; -export default IndexPage; +LoginPage.layout = WithDefaultLayout; +export default LoginPage; + \ No newline at end of file diff --git a/pages/login.tsx b/pages/login.tsx new file mode 100644 index 0000000..809c351 --- /dev/null +++ b/pages/login.tsx @@ -0,0 +1,55 @@ +import { WithDefaultLayout } from "@/components/DefautLayout" +import { Page } from "@/types/Page" +import { Controller, useForm } from "react-hook-form" +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from "zod"; +import { Button, Input } from "antd"; + +const LoginPage: Page = () => { + + const LoginFormSchema = z.object({ + username: z.string().nonempty({ message: '' }) + .max(50, { message: '' }), + password: z.string().nonempty({ message: '' }) + .max(50, { message: '' }), + }); + + type LoginFormType = z.infer; + + const { handleSubmit, control, formState: { errors } } = useForm({ + resolver: zodResolver(LoginFormSchema), + mode: 'onChange' + }); + + function onFormSubmit() { + console.log("LOGIN FORM SUBMIT"); + } + + return <> + +
+ + } + /> + {errors.username && {errors.username.message}} + + } + /> + {errors.password && {errors.password.message}} + +

Dont have an account? sign up

+ + + + + +} + +LoginPage.layout = WithDefaultLayout; +export default LoginPage; \ No newline at end of file diff --git a/pages/orders/[id].tsx b/pages/orders/[id].tsx new file mode 100644 index 0000000..f580f11 --- /dev/null +++ b/pages/orders/[id].tsx @@ -0,0 +1,42 @@ +import { WithDefaultLayout } from "@/components/DefautLayout" +import { useSwrFetcherWithAccessToken } from "@/functions/useSwrFetcherWithAccessToken"; +import { OrderData } from "@/types/OrderData"; +import { Page } from "@/types/Page" +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useRouter } from "next/router"; + + +const RegisterPage: Page = () => { + + const router = useRouter(); + const { id } = router.query; + + const queryFetcher = useSwrFetcherWithAccessToken(); + + const { data } = useQuery( + { + queryKey: ['orderDetail' + id], + queryFn: async () => await queryFetcher(`/api/be/api/v1/Order/OrderDetail/${id}`) + } + ); + + return ( + <> +

ID : {data?.orderId}

+

From : {data?.orderFrom}

+

To : {data?.orderTo}

+

Quantity : {data?.quantity}

+

Total : {data?.total}

+

Ordered at :{data?.orderedAt}

+ +
+ + Home + + + ) +} + +RegisterPage.layout = WithDefaultLayout; +export default RegisterPage; \ No newline at end of file diff --git a/pages/register.tsx b/pages/register.tsx new file mode 100644 index 0000000..62fd693 --- /dev/null +++ b/pages/register.tsx @@ -0,0 +1,153 @@ +import { WithDefaultLayout } from "@/components/DefautLayout" +import registeredEmailsAtom from "@/data/RegisteredEmails"; +import { Page } from "@/types/Page" +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, DatePicker, Input, Radio, Space } from "antd"; +import dayjs from "dayjs"; +import { useAtom } from "jotai"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; +import { redirect } from 'next/navigation'; + +const RegisterPage: Page = () => { + + const [registeredEmails, setRegisteredEmails] = useAtom(registeredEmailsAtom); + + const LoginFormSchema = z.object({ + + email: z.string() + .nonempty({ message: 'Email cannot be empty.' }) + .email() + .max(50, { message: 'Email must be less than 50 characters.' }) + .refine((value) => !registeredEmails.includes(value), {message: 'Email already used.'}), + + birthdate: z.string() + .nonempty({ message: 'Date of birth cannot be empty.' }) + .refine((value) => dayjs(value, 'YYYY-MM-DD').isValid(), { message: 'Invalid date format.' }) + .refine((value) => { + const dateOfBirth = dayjs(value, 'YYYY-MM-DD'); + const age = dayjs().diff(dateOfBirth, 'years'); + return age >= 14; + }, { message: 'You must be at least 14 years old.' } + ), + + gender: z.string() + .nonempty({ message: '' }), + + username: z.string() + .nonempty({ message: 'Username cannot be empty' }) + .max(20, { message: 'Username must be less than 20 characters.' }), + + address: z.string() + .nonempty({ message: 'Address cannot be empty' }) + .max(255, { message: 'Address must be less than 255 characters.' }), + + password: z.string() + .nonempty({ message: '' }) + .min(8, { message: 'Password must be at least 8 characters.' }) + .max(64, { message: 'Password must be at at most 64 characters.' }), + }); + + type LoginFormType = z.infer; + + const { handleSubmit, control, formState: { errors } } = useForm({ + resolver: zodResolver(LoginFormSchema), + mode: 'onChange' + }); + + async function onFormSubmit(formData) { + + const reqInit: RequestInit = { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(formData), + } + + try { + await fetch('http://localhost:3000/api/be/api/v1/Auth/Register', reqInit); + + const tempRegisteredEmails = registeredEmails.slice(0); + tempRegisteredEmails.push(formData.email); + setRegisteredEmails(tempRegisteredEmails); + + //TODO confirmation + + //TODO redirect to login + + } catch (error) { + console.error(error); + } + } + + return <> + +
+ + + + } + /> + {errors.email && {errors.email.message}} + + ( + field.onChange(date?.format('YYYY-MM-DD'))} + /> + )} + /> + {errors.birthdate && {errors.birthdate.message}} + + ( + + Male + Female + Other + + )} + /> + + } + /> + {errors.address && {errors.address.message}} + + } + /> + {errors.username && {errors.username.message}} + + } + /> + {errors.password && {errors.password.message}} + + + +

Already have an account? Log in

+ +
+ +
+ +} + +RegisterPage.layout = WithDefaultLayout; +export default RegisterPage; \ No newline at end of file diff --git a/types/OrderData.ts b/types/OrderData.ts new file mode 100644 index 0000000..2f4a9e5 --- /dev/null +++ b/types/OrderData.ts @@ -0,0 +1,8 @@ +export interface OrderData { + orderId: number, + orderFrom: string, + orderTo: string, + total: number, + quantity: number, + orderedAt: string, +} \ No newline at end of file From 12f7484b4fab9a85a2fe1f440213b6b34319e5fd Mon Sep 17 00:00:00 2001 From: ammarmelade Date: Thu, 25 Apr 2024 18:38:45 +0700 Subject: [PATCH 2/5] Initial commit --- components/DefautLayout.tsx | 2 +- pages/index.tsx | 60 +++++++++++++++++++------------------ pages/login.tsx | 3 +- pages/register.tsx | 4 +-- 4 files changed, 36 insertions(+), 33 deletions(-) diff --git a/components/DefautLayout.tsx b/components/DefautLayout.tsx index b964063..0556b3d 100644 --- a/components/DefautLayout.tsx +++ b/components/DefautLayout.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import Head from 'next/head'; import { Avatar, Button, ConfigProvider, Drawer, Layout, Menu, MenuProps } from "antd"; -import { faBars, faSignOut, faSignIn, faHome, faCubes, faUser, faUsers, faFlaskVial } from '@fortawesome/free-solid-svg-icons' +import { faBars, faSignOut, faSignIn, faHome, faUser } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter } from "next/router"; import { useSession, signIn, signOut } from "next-auth/react"; diff --git a/pages/index.tsx b/pages/index.tsx index 53c2464..15fc09b 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEye, faPenToSquare, faSort, faTrash } from "@fortawesome/free-solid-svg-icons"; import { OrderData } from "@/types/OrderData"; +import Link from "next/link"; function OrderTable({ rowNumber, rowData, currentPage }) { @@ -38,15 +39,15 @@ function OrderTable({ rowNumber, rowData, currentPage }) { - + - - + + - - + + - + @@ -65,32 +66,33 @@ const LoginPage: Page = () => { const [orderList, setOrderList] = useState([]) - async function fetchData() { - - const requestData = { - currentPage: currentPage, - pageSize: 25, - }; + useEffect(() => { + const fetchAndSetData = async () => { - const reqInit: RequestInit = { - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - body: JSON.stringify(requestData), - } + async function fetchData() { - try { - const response = await fetch('http://localhost:3000/api/be/api/v1/Order/OrderGrid', reqInit); - const responseData = await response.json(); - return responseData; - } catch (error) { - console.error(error); - } - } + const requestData = { + currentPage: 1, + pageSize: 25, + }; + + const reqInit: RequestInit = { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(requestData), + } + + try { + const response = await fetch('http://localhost:3000/api/be/api/v1/Order/OrderGrid', reqInit); + const responseData = await response.json(); + return responseData; + } catch (error) { + console.error(error); + } + } - useEffect(() => { - const fetchAndSetData = async () => { const data = await fetchData(); setOrderList(data); }; diff --git a/pages/login.tsx b/pages/login.tsx index 809c351..b850233 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -4,6 +4,7 @@ import { Controller, useForm } from "react-hook-form" import { zodResolver } from '@hookform/resolvers/zod'; import { z } from "zod"; import { Button, Input } from "antd"; +import Link from "next/link"; const LoginPage: Page = () => { @@ -43,7 +44,7 @@ const LoginPage: Page = () => { /> {errors.password && {errors.password.message}} -

Dont have an account? sign up

+

Dont have an account? sign up

diff --git a/pages/register.tsx b/pages/register.tsx index 62fd693..3b168d3 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -5,9 +5,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button, DatePicker, Input, Radio, Space } from "antd"; import dayjs from "dayjs"; import { useAtom } from "jotai"; +import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; -import { redirect } from 'next/navigation'; const RegisterPage: Page = () => { @@ -141,7 +141,7 @@ const RegisterPage: Page = () => { -

Already have an account? Log in

+

Already have an account? Log in

From 2fb26cffa8500eb1caef93dcf4091e712731ddb8 Mon Sep 17 00:00:00 2001 From: ammarmelade Date: Thu, 25 Apr 2024 19:15:50 +0700 Subject: [PATCH 3/5] Added placeholder pages for login, create order, and update order --- components/DefautLayout.tsx | 9 +- pages/api/auth/[...nextauth].ts | 89 +++++++------------ pages/index.tsx | 2 +- pages/login.tsx | 19 +++- pages/orders/create.tsx | 153 ++++++++++++++++++++++++++++++++ pages/orders/update/[id].tsx | 153 ++++++++++++++++++++++++++++++++ 6 files changed, 364 insertions(+), 61 deletions(-) create mode 100644 pages/orders/create.tsx create mode 100644 pages/orders/update/[id].tsx diff --git a/components/DefautLayout.tsx b/components/DefautLayout.tsx index 0556b3d..879157d 100644 --- a/components/DefautLayout.tsx +++ b/components/DefautLayout.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import Head from 'next/head'; import { Avatar, Button, ConfigProvider, Drawer, Layout, Menu, MenuProps } from "antd"; -import { faBars, faSignOut, faSignIn, faHome, faUser } from '@fortawesome/free-solid-svg-icons' +import { faBars, faSignOut, faSignIn, faHome, faUser, faPlus } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter } from "next/router"; import { useSession, signIn, signOut } from "next-auth/react"; @@ -34,6 +34,13 @@ const DefaultLayout: React.FC<{ onClick: () => router.push('/') }); + menu.push({ + key: '/orders/create', + label: 'Create new order', + icon: , + onClick: () => router.push('/orders/create') + }); + if (status === 'authenticated') { menu.push({ key: '/sign-out', diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index f981022..a481e36 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -4,6 +4,7 @@ import { Issuer } from 'openid-client'; import { custom } from 'openid-client'; import { AppSettings } from "../../../functions/AppSettings" import { UserInfo } from "../../../functions/AuthorizationContext"; +import CredentialsProvider from "next-auth/providers/credentials"; /** * Takes a token, and returns a new token with updated @@ -67,62 +68,37 @@ function hasNotExpired(expireAtSeconds: unknown): boolean { export const authOptions: NextAuthOptions = { providers: [ - { - id: "oidc", - name: "OpenID Connect", - type: "oauth", - wellKnown: AppSettings.current.oidcIssuer + '/.well-known/openid-configuration', - client: { - token_endpoint_auth_method: 'none' - }, - clientId: AppSettings.current.oidcClientId, - authorization: { - params: { - scope: AppSettings.current.oidcScope, + CredentialsProvider({ + name: 'Credentials', + credentials: { + username: { label: "Username", type: "text", placeholder: "admin@accelist.com" }, + password: { label: "Password", type: "password", placeholder: "admin" } + }, + async authorize(credentials) { + + try { + const res = await fetch("http://localhost:3000/api/be/api/v1/Auth/Login", { + method: 'POST', + body: JSON.stringify(credentials), + headers: { "Content-Type": "application/json" } + }); + + const user = await res.json(); + + // If no error and we have user data, return it + if (res.ok && user) { + return user; } - }, - checks: ["pkce", "state"], - idToken: true, - userinfo: { - async request(context) { - // idToken: true makes next-auth parse user info from id_token - // this code below makes next-auth query the user info endpoint instead - if (context.tokens.access_token) { - return await context.client.userinfo(context.tokens.access_token) - } - return {}; - } - }, - async profile(profile) { - // add claims obtained from user info endpoint to the session.user data - // reference: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - // console.log(profile); - return { - id: profile.sub, - name: profile.name, - // given_name: profile.given_name, - // family_name: profile.family_name, - // middle_name: profile.middle_name, - // nickname: profile.nickname, - // preferred_username: profile.preferred_username, - // profile: profile.profile, - // picture: profile.picture, - // website: profile.website, - email: profile.email, - // email_verified: profile.email_verified, - // gender: profile.gender, - // birthdate: profile.birthdate, - // zoneinfo: profile.zoneinfo, - // locale: profile.locale, - // phone_number: profile.phone_number, - // phone_number_verified: profile.phone_number_verified, - // address: profile.address, - // updated_at: profile.updated_at - role: profile.role - } - }, - } - ], + + // Return null if user data could not be retrieved + return null; + } catch (error) { + console.error('Authorization error:', error); + throw new Error('Authorization failed'); + } + } + }) + ], callbacks: { async jwt({ token, account, user }) { // Initial sign in @@ -159,5 +135,4 @@ export const authOptions: NextAuthOptions = { export default NextAuth(authOptions) -// generate new NEXTAUTH_SECRET for production -// https://generate-secret.vercel.app/32 + diff --git a/pages/index.tsx b/pages/index.tsx index 15fc09b..6c78fcc 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -42,7 +42,7 @@ function OrderTable({ rowNumber, rowData, currentPage }) { - + diff --git a/pages/login.tsx b/pages/login.tsx index b850233..3df3604 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -5,6 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from "zod"; import { Button, Input } from "antd"; import Link from "next/link"; +import { signIn } from "next-auth/react"; const LoginPage: Page = () => { @@ -22,8 +23,22 @@ const LoginPage: Page = () => { mode: 'onChange' }); - function onFormSubmit() { - console.log("LOGIN FORM SUBMIT"); + async function onFormSubmit(formData) { + + try { + const result = await signIn('credentials', { + username: formData.username, + password: formData.password, + callbackUrl: '/', + redirect: true + }); + + if (result?.error) { + console.error('Authentication failed:', result.error); + } + } catch (error) { + console.error('Sign in error:', error); + } } return <> diff --git a/pages/orders/create.tsx b/pages/orders/create.tsx new file mode 100644 index 0000000..5f48c53 --- /dev/null +++ b/pages/orders/create.tsx @@ -0,0 +1,153 @@ +import { WithDefaultLayout } from "@/components/DefautLayout" +import registeredEmailsAtom from "@/data/RegisteredEmails"; +import { Page } from "@/types/Page" +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, DatePicker, Input, Radio, Space } from "antd"; +import dayjs from "dayjs"; +import { useAtom } from "jotai"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; + +const CreateNewOrder: Page = () => { + + const [registeredEmails, setRegisteredEmails] = useAtom(registeredEmailsAtom); + + const LoginFormSchema = z.object({ + + email: z.string() + .nonempty({ message: 'Email cannot be empty.' }) + .email() + .max(50, { message: 'Email must be less than 50 characters.' }) + .refine((value) => !registeredEmails.includes(value), {message: 'Email already used.'}), + + birthdate: z.string() + .nonempty({ message: 'Date of birth cannot be empty.' }) + .refine((value) => dayjs(value, 'YYYY-MM-DD').isValid(), { message: 'Invalid date format.' }) + .refine((value) => { + const dateOfBirth = dayjs(value, 'YYYY-MM-DD'); + const age = dayjs().diff(dateOfBirth, 'years'); + return age >= 14; + }, { message: 'You must be at least 14 years old.' } + ), + + gender: z.string() + .nonempty({ message: '' }), + + username: z.string() + .nonempty({ message: 'Username cannot be empty' }) + .max(20, { message: 'Username must be less than 20 characters.' }), + + address: z.string() + .nonempty({ message: 'Address cannot be empty' }) + .max(255, { message: 'Address must be less than 255 characters.' }), + + password: z.string() + .nonempty({ message: '' }) + .min(8, { message: 'Password must be at least 8 characters.' }) + .max(64, { message: 'Password must be at at most 64 characters.' }), + }); + + type LoginFormType = z.infer; + + const { handleSubmit, control, formState: { errors } } = useForm({ + resolver: zodResolver(LoginFormSchema), + mode: 'onChange' + }); + + async function onFormSubmit(formData) { + + const reqInit: RequestInit = { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(formData), + } + + try { + await fetch('http://localhost:3000/api/be/api/v1/Auth/Register', reqInit); + + const tempRegisteredEmails = registeredEmails.slice(0); + tempRegisteredEmails.push(formData.email); + setRegisteredEmails(tempRegisteredEmails); + + //TODO confirmation + + //TODO redirect to login + + } catch (error) { + console.error(error); + } + } + + return <> + +
+ + + + } + /> + {errors.email && {errors.email.message}} + + ( + field.onChange(date?.format('YYYY-MM-DD'))} + /> + )} + /> + {errors.birthdate && {errors.birthdate.message}} + + ( + + Male + Female + Other + + )} + /> + + } + /> + {errors.address && {errors.address.message}} + + } + /> + {errors.username && {errors.username.message}} + + } + /> + {errors.password && {errors.password.message}} + + + +

Already have an account? Log in

+ +
+ +
+ +} + +CreateNewOrder.layout = WithDefaultLayout; +export default CreateNewOrder; \ No newline at end of file diff --git a/pages/orders/update/[id].tsx b/pages/orders/update/[id].tsx new file mode 100644 index 0000000..09389ad --- /dev/null +++ b/pages/orders/update/[id].tsx @@ -0,0 +1,153 @@ +import { WithDefaultLayout } from "@/components/DefautLayout" +import registeredEmailsAtom from "@/data/RegisteredEmails"; +import { Page } from "@/types/Page" +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, DatePicker, Input, Radio, Space } from "antd"; +import dayjs from "dayjs"; +import { useAtom } from "jotai"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; + +const UpdateOrderPage: Page = () => { + + const [registeredEmails, setRegisteredEmails] = useAtom(registeredEmailsAtom); + + const LoginFormSchema = z.object({ + + email: z.string() + .nonempty({ message: 'Email cannot be empty.' }) + .email() + .max(50, { message: 'Email must be less than 50 characters.' }) + .refine((value) => !registeredEmails.includes(value), {message: 'Email already used.'}), + + birthdate: z.string() + .nonempty({ message: 'Date of birth cannot be empty.' }) + .refine((value) => dayjs(value, 'YYYY-MM-DD').isValid(), { message: 'Invalid date format.' }) + .refine((value) => { + const dateOfBirth = dayjs(value, 'YYYY-MM-DD'); + const age = dayjs().diff(dateOfBirth, 'years'); + return age >= 14; + }, { message: 'You must be at least 14 years old.' } + ), + + gender: z.string() + .nonempty({ message: '' }), + + username: z.string() + .nonempty({ message: 'Username cannot be empty' }) + .max(20, { message: 'Username must be less than 20 characters.' }), + + address: z.string() + .nonempty({ message: 'Address cannot be empty' }) + .max(255, { message: 'Address must be less than 255 characters.' }), + + password: z.string() + .nonempty({ message: '' }) + .min(8, { message: 'Password must be at least 8 characters.' }) + .max(64, { message: 'Password must be at at most 64 characters.' }), + }); + + type LoginFormType = z.infer; + + const { handleSubmit, control, formState: { errors } } = useForm({ + resolver: zodResolver(LoginFormSchema), + mode: 'onChange' + }); + + async function onFormSubmit(formData) { + + const reqInit: RequestInit = { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(formData), + } + + try { + await fetch('http://localhost:3000/api/be/api/v1/Auth/Register', reqInit); + + const tempRegisteredEmails = registeredEmails.slice(0); + tempRegisteredEmails.push(formData.email); + setRegisteredEmails(tempRegisteredEmails); + + //TODO confirmation + + //TODO redirect to login + + } catch (error) { + console.error(error); + } + } + + return <> + +
+ + + + } + /> + {errors.email && {errors.email.message}} + + ( + field.onChange(date?.format('YYYY-MM-DD'))} + /> + )} + /> + {errors.birthdate && {errors.birthdate.message}} + + ( + + Male + Female + Other + + )} + /> + + } + /> + {errors.address && {errors.address.message}} + + } + /> + {errors.username && {errors.username.message}} + + } + /> + {errors.password && {errors.password.message}} + + + +

Already have an account? Log in

+ +
+ +
+ +} + +UpdateOrderPage.layout = WithDefaultLayout; +export default UpdateOrderPage; \ No newline at end of file From b581f16edfb1bd89590b1e598767273aab65a2f4 Mon Sep 17 00:00:00 2001 From: ammarmelade Date: Thu, 25 Apr 2024 19:26:45 +0700 Subject: [PATCH 4/5] Tried adding delete functionality --- pages/index.tsx | 143 +++++++++++++++++++++++++++++++----------------- 1 file changed, 92 insertions(+), 51 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index 6c78fcc..b2c7b60 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -5,56 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEye, faPenToSquare, faSort, faTrash } from "@fortawesome/free-solid-svg-icons"; import { OrderData } from "@/types/OrderData"; import Link from "next/link"; - -function OrderTable({ rowNumber, rowData, currentPage }) { - - return <> - - - - - - - {((currentPage - 1) * 5)+ rowNumber + 1} - - - - {rowData.orderFrom} - - - - {rowData.orderTo} - - - - {rowData.quantity} - - - - {rowData.total} - - - - {rowData.orderedAt} - - - - - - - - - - - - - - - - - - -} +import { Modal } from "antd"; const LoginPage: Page = () => { @@ -99,6 +50,95 @@ const LoginPage: Page = () => { fetchAndSetData(); }, []); + + const [modal, contextHolder] = Modal.useModal(); + + function onClickDeleteOrder(order) { + modal.confirm({ + title: 'Delete Confirmation', + content: `Are you sure you want to delete order?`, + okButtonProps: { + className: 'bg-red-500 text-white' + }, + okText: 'Yes', + onOk: () => onConfirmDeleteOrder(order), + cancelText: 'No', + }); + } + + /** + * On click confirm delete product. + * @param product + */ + async function onConfirmDeleteOrder(order) { + + const reqInit: RequestInit = { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + } + + try { + await fetch(`http://localhost:3000/api/be/api/v1/Order/DeleteOrder/${order.orderId}`, reqInit); + + //TODO confirmation + + } catch (error) { + console.error(error); + } + } + + + function orderTable(rowNumber, rowData, currentPage) { + + return <> + + + + + + + {((currentPage - 1) * 5)+ rowNumber + 1} + + + + {rowData.orderFrom} + + + + {rowData.orderTo} + + + + {rowData.quantity} + + + + {rowData.total} + + + + {rowData.orderedAt} + + + + + + + + + + onClickDeleteOrder(rowData)} className="font-medium text-blue-600 dark:text-blue-500 hover:underline mr-2"> + + + + + + + + + } // const { data } = useQuery( // { @@ -203,7 +243,7 @@ const LoginPage: Page = () => { {orderList && orderList.slice(((currentPage - 1) * 5), ((currentPage - 1) * 5) + 5).map((item, index) => ( - + orderTable(index, item, currentPage) ))} @@ -231,6 +271,7 @@ const LoginPage: Page = () => { + {contextHolder} } From 9ca9cce9478e1874c32847e07233e6f6087518b5 Mon Sep 17 00:00:00 2001 From: ammarmelade Date: Thu, 25 Apr 2024 19:28:09 +0700 Subject: [PATCH 5/5] Added delete order functionality --- pages/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/index.tsx b/pages/index.tsx index b2c7b60..7417fe9 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -76,7 +76,7 @@ const LoginPage: Page = () => { headers: { 'Content-Type': 'application/json', }, - method: 'POST', + method: 'DELETE', } try {