first commit
This commit is contained in:
34
frontend/src/routes/__root.tsx
Normal file
34
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||
import React, { Suspense } from "react"
|
||||
|
||||
import NotFound from "../components/Common/NotFound"
|
||||
|
||||
const loadDevtools = () =>
|
||||
Promise.all([
|
||||
import("@tanstack/router-devtools"),
|
||||
import("@tanstack/react-query-devtools"),
|
||||
]).then(([routerDevtools, reactQueryDevtools]) => {
|
||||
return {
|
||||
default: () => (
|
||||
<>
|
||||
<routerDevtools.TanStackRouterDevtools />
|
||||
<reactQueryDevtools.ReactQueryDevtools />
|
||||
</>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const TanStackDevtools =
|
||||
process.env.NODE_ENV === "production" ? () => null : React.lazy(loadDevtools)
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
<Suspense>
|
||||
<TanStackDevtools />
|
||||
</Suspense>
|
||||
</>
|
||||
),
|
||||
notFoundComponent: () => <NotFound />,
|
||||
})
|
35
frontend/src/routes/_layout.tsx
Normal file
35
frontend/src/routes/_layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Flex, Spinner } from "@chakra-ui/react"
|
||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import Sidebar from "../components/Common/Sidebar"
|
||||
import UserMenu from "../components/Common/UserMenu"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout")({
|
||||
component: Layout,
|
||||
beforeLoad: async () => {
|
||||
if (!isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/login",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function Layout() {
|
||||
const { isLoading } = useAuth()
|
||||
|
||||
return (
|
||||
<Flex maxW="large" h="auto" position="relative">
|
||||
<Sidebar />
|
||||
{isLoading ? (
|
||||
<Flex justify="center" align="center" height="100vh" width="full">
|
||||
<Spinner size="xl" color="ui.main" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
<UserMenu />
|
||||
</Flex>
|
||||
)
|
||||
}
|
73
frontend/src/routes/_layout/Courses/$id.EditCourse.tsx
Normal file
73
frontend/src/routes/_layout/Courses/$id.EditCourse.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient, useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { UserPublic, CoursesService } from "../../../client"
|
||||
|
||||
import CourseDetails from "../../../components/Courses/CourseDetails"
|
||||
import CourseImages from "../../../components/Courses/CourseImages"
|
||||
import CourseInfoImages from "../../../components/Courses/CourseInfoImages"
|
||||
import Sechedule from "../../../components/Courses/Sechedule"
|
||||
|
||||
const tabsConfig = [
|
||||
{ title: "Course Details", component: CourseDetails },
|
||||
{ title: "Course Images", component: CourseImages },
|
||||
{ title: "Course Info Images", component: CourseInfoImages },
|
||||
{ title: "Schedule", component: Sechedule },
|
||||
|
||||
]
|
||||
|
||||
export const Route = createFileRoute('/_layout/Courses/$id/EditCourse')({
|
||||
component: EditCourse,
|
||||
})
|
||||
|
||||
function getcoursesQueryOptions(id: string) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
CoursesService.readCourse({ id: id }),
|
||||
queryKey: ["course"],
|
||||
}
|
||||
}
|
||||
|
||||
function EditCourse() {
|
||||
|
||||
const {
|
||||
data: course,
|
||||
} = useQuery(
|
||||
{
|
||||
...getcoursesQueryOptions(Route.useParams().id),
|
||||
// placeholderData: (prevData) => prevData,
|
||||
}
|
||||
)
|
||||
console.log(course)
|
||||
const finalTabs = tabsConfig.slice(0, 4)
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
|
||||
User Settings
|
||||
</Heading>
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<Tab key={index}>{tab.title}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<TabPanel key={index}>
|
||||
{<tab.component />}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
234
frontend/src/routes/_layout/Courses/AddCourse.tsx
Normal file
234
frontend/src/routes/_layout/Courses/AddCourse.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../../hooks/useCustomToast"
|
||||
import { CoursesService, type ApiError, CourseCreate, } from "../../../client"
|
||||
import { handleError } from "../../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_layout/Courses/AddCourse")({
|
||||
component: AddCourse,
|
||||
})
|
||||
|
||||
|
||||
function AddCourseForms() {
|
||||
|
||||
const toolbar = {
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}
|
||||
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [contentEditorState, setContentEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [contents, setContent] = useState<string>('');
|
||||
const [infoEditorState, setInfoEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [info, setInfo] = useState<string>('');
|
||||
const [longDescriptionEditorState, setLongDescriptionEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [longDescription, setlongDescription] = useState<string>('');
|
||||
const [remarksEditorState, setRemarksEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [remarks, setRemarks] = useState<string>('');
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<CourseCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: "",
|
||||
sort_description: "",
|
||||
long_description: "",
|
||||
information: "",
|
||||
contant: "",
|
||||
remark: "",
|
||||
}
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: CourseCreate) =>
|
||||
CoursesService.createCourse({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Course create successfully.", "success")
|
||||
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
history.go(-1)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<CourseCreate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Container maxW="full" mt="20" as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
{...register("title", {
|
||||
required: "title is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="sort_description">Short Description</FormLabel>
|
||||
<Textarea
|
||||
id="sort_description"
|
||||
{...register("sort_description", {
|
||||
required: "sort_description is required",
|
||||
})}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="long_description">Long Description</FormLabel>
|
||||
<Editor
|
||||
editorState={longDescriptionEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setLongDescriptionEditorState(newState);
|
||||
setlongDescription(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("long_description", longDescription);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="information">Information</FormLabel>
|
||||
<Editor
|
||||
editorState={infoEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setInfoEditorState(newState);
|
||||
setInfo(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("information", info);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="contant">Content</FormLabel>
|
||||
<Editor
|
||||
editorState={contentEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setContentEditorState(newState);
|
||||
setContent(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("contant", contents);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="remark">Remark</FormLabel>
|
||||
<Editor
|
||||
editorState={remarksEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setRemarksEditorState(newState);
|
||||
setRemarks(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("remark", remarks);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
|
||||
<FormControl mt={4}></FormControl>
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
|
||||
</Container>
|
||||
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function AddCourse() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
|
||||
<AddCourseForms />
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
193
frontend/src/routes/_layout/Courses/Courses.tsx
Normal file
193
frontend/src/routes/_layout/Courses/Courses.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Icon,
|
||||
useDisclosure
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { z } from "zod"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { CoursesService } from "../../../client"
|
||||
import Delete from "../../../components/Common/DeleteAlert"
|
||||
import ActionsMenu from "../../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../../components/Common/Navbar"
|
||||
//import Addcourse from "../../components/courses/Addcourse"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
const CoursesSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/Courses/Courses")({
|
||||
component: Courses,
|
||||
validateSearch: (search) => CoursesSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getcoursesQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
CoursesService.readCourses({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["courses", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function CoursesTable() {
|
||||
const [id, setId] = useState<string>('');
|
||||
const deleteModal = useDisclosure()
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: courses,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getcoursesQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && courses?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getcoursesQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Short description</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{courses?.data.map((course) => (
|
||||
<Tr key={course.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="20px">{course.id}</Td>
|
||||
<Td isTruncated maxWidth="50px">
|
||||
{course.title}
|
||||
</Td>
|
||||
<Td
|
||||
|
||||
isTruncated
|
||||
maxWidth="250px"
|
||||
>
|
||||
{course.sort_description || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
as={Link}
|
||||
to={`/Courses/${course.id}/EditCourse`}
|
||||
>
|
||||
<Icon as={FaPen} />
|
||||
</Button>
|
||||
<Button
|
||||
marginLeft={2}
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm" }}
|
||||
onClick={() => {
|
||||
setId(course.id)
|
||||
deleteModal.onOpen()
|
||||
}}
|
||||
>
|
||||
<Icon as={FaTrashAlt} />
|
||||
</Button>
|
||||
</Td>
|
||||
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Delete
|
||||
type={'Course'}
|
||||
id={id}
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={deleteModal.onClose}
|
||||
/>
|
||||
<Flex
|
||||
gap={4}
|
||||
//aligncourses="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Courses() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Courses Management
|
||||
</Heading>
|
||||
<Flex py={8} gap={4}>
|
||||
{/* TODO: Complete search functionality */}
|
||||
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
|
||||
<InputLeftElement pointerEvents='none'>
|
||||
<Icon as={FaSearch} color='ui.dim' />
|
||||
</InputLeftElement>
|
||||
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
|
||||
</InputGroup> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
as={Link} to="/Courses/AddCourse"
|
||||
>
|
||||
<Icon as={FaPlus} /> Add Course
|
||||
</Button>
|
||||
</Flex>
|
||||
<CoursesTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
155
frontend/src/routes/_layout/aboutUs.tsx
Normal file
155
frontend/src/routes/_layout/aboutUs.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { z } from "zod"
|
||||
import parse from 'html-react-parser';
|
||||
import { AboutUsService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddAboutUs from "../../components/AboutUs/AddAboutUs"
|
||||
|
||||
|
||||
|
||||
|
||||
const aboutUsSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/aboutUs")({
|
||||
component: AboutUs,
|
||||
validateSearch: (search) => aboutUsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getItemsQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
AboutUsService.readAboutUs({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["aboutUs", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function AboutUsTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: aboutUs,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getItemsQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
aboutUs?.data.sort((a, b) => a.index - b.index)
|
||||
const hasNextPage = !isPlaceholderData && aboutUs?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Image</Th>
|
||||
<Th>Index</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{aboutUs?.data.map((aboutUs) => (
|
||||
<Tr key={aboutUs.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="50">{aboutUs.id}</Td>
|
||||
|
||||
<Td
|
||||
whiteSpace="pre-line"
|
||||
maxWidth="350px"
|
||||
>
|
||||
{parse(aboutUs.description) || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<img src={import.meta.env.VITE_API_URL+"/"+aboutUs.image} width="100px" height="100px" />
|
||||
</Td>
|
||||
<Td isTruncated maxWidth="10px">
|
||||
{aboutUs.index}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"AboutUs"} value={aboutUs} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutUs() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
About Us Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"AboutUs"} addModalAs={AddAboutUs} />
|
||||
<AboutUsTable />
|
||||
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
170
frontend/src/routes/_layout/admin.tsx
Normal file
170
frontend/src/routes/_layout/admin.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type UserPublic, UsersService } from "../../client"
|
||||
import AddUser from "../../components/Admin/AddUser"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
|
||||
const usersSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/admin")({
|
||||
component: Admin,
|
||||
validateSearch: (search) => usersSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getUsersQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
UsersService.readUsers({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["users", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function UsersTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getUsersQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th width="20%">Full name</Th>
|
||||
<Th width="50%">Email</Th>
|
||||
<Th width="10%">Role</Th>
|
||||
<Th width="10%">Status</Th>
|
||||
<Th width="10%">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{users?.data.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td
|
||||
color={!user.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
{user.full_name || "N/A"}
|
||||
{currentUser?.id === user.id && (
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{user.email}
|
||||
</Td>
|
||||
<Td>{user.is_superuser ? "Superuser" : "User"}</Td>
|
||||
<Td>
|
||||
<Flex gap={2}>
|
||||
<Box
|
||||
w="2"
|
||||
h="2"
|
||||
borderRadius="50%"
|
||||
bg={user.is_active ? "ui.success" : "ui.danger"}
|
||||
alignSelf="center"
|
||||
/>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu
|
||||
type="User"
|
||||
value={user}
|
||||
disabled={currentUser?.id === user.id ? true : false}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Admin() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Users Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"User"} addModalAs={AddUser} />
|
||||
<UsersTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
150
frontend/src/routes/_layout/clientMessages.tsx
Normal file
150
frontend/src/routes/_layout/clientMessages.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
import moment from 'moment';
|
||||
import { ClientMessagesService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
|
||||
|
||||
const messagesSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/clientMessages")({
|
||||
component: Messages,
|
||||
validateSearch: (search) => messagesSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getMessagesQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
ClientMessagesService.readMessages({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["messages", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function MessagesTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: messages,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getMessagesQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && messages?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
messages?.data.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getMessagesQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>id</Th>
|
||||
<Th>Name</Th>
|
||||
<Th>Phone</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Message</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{messages?.data.map((message) => (
|
||||
<Tr key={message.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="50">{message.id}</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.name}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.phone}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.email}
|
||||
</Td>
|
||||
<Td whiteSpace="pre-line" maxWidth="250px">
|
||||
{message.message}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{moment(message.created_at).utcOffset("+08:00").format('DD-MM-YYYY HH:mm')}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Message"} value={message} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Messages() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Messages Management
|
||||
</Heading>
|
||||
<MessagesTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
25
frontend/src/routes/_layout/index.tsx
Normal file
25
frontend/src/routes/_layout/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Box, Container, Text } from "@chakra-ui/react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout/")({
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
function Dashboard() {
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Box pt={12} m={4}>
|
||||
<Text fontSize="2xl">
|
||||
Hi, {currentUser?.full_name || currentUser?.email} 👋🏼
|
||||
</Text>
|
||||
<Text>Welcome back, nice to see you again!</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
145
frontend/src/routes/_layout/items.tsx
Normal file
145
frontend/src/routes/_layout/items.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { ItemsService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddItem from "../../components/Items/AddItem"
|
||||
|
||||
const itemsSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
validateSearch: (search) => itemsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getItemsQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
ItemsService.readItems({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["items", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function ItemsTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getItemsQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{items?.data.map((item) => (
|
||||
<Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td>{item.id}</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{item.title}
|
||||
</Td>
|
||||
<Td
|
||||
color={!item.description ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
{item.description || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Item"} value={item} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Items() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Items Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"Item"} addModalAs={AddItem} />
|
||||
<ItemsTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
58
frontend/src/routes/_layout/settings.tsx
Normal file
58
frontend/src/routes/_layout/settings.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import type { UserPublic } from "../../client"
|
||||
import Appearance from "../../components/UserSettings/Appearance"
|
||||
import ChangePassword from "../../components/UserSettings/ChangePassword"
|
||||
import DeleteAccount from "../../components/UserSettings/DeleteAccount"
|
||||
import UserInformation from "../../components/UserSettings/UserInformation"
|
||||
|
||||
const tabsConfig = [
|
||||
{ title: "My profile", component: UserInformation },
|
||||
{ title: "Password", component: ChangePassword },
|
||||
{ title: "Appearance", component: Appearance },
|
||||
{ title: "Danger zone", component: DeleteAccount },
|
||||
]
|
||||
|
||||
export const Route = createFileRoute("/_layout/settings")({
|
||||
component: UserSettings,
|
||||
})
|
||||
|
||||
function UserSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const finalTabs = currentUser?.is_superuser
|
||||
? tabsConfig.slice(0, 3)
|
||||
: tabsConfig
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
|
||||
User Settings
|
||||
</Heading>
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<Tab key={index}>{tab.title}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<TabPanel key={index}>
|
||||
<tab.component />
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Container>
|
||||
)
|
||||
}
|
269
frontend/src/routes/_layout/webSetting.tsx
Normal file
269
frontend/src/routes/_layout/webSetting.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { WebSettingsService, type WebSettingUpdate, type ApiError, } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_layout/webSetting")({
|
||||
component: WebSetting,
|
||||
})
|
||||
|
||||
|
||||
|
||||
function getWebSettingQuery() {
|
||||
return {
|
||||
queryFn: () =>
|
||||
WebSettingsService.readWebSetting(),
|
||||
queryKey: ["webSetting"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function WebSettingForms() {
|
||||
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// const {
|
||||
// data: webSetting,
|
||||
// isPending,
|
||||
// isPlaceholderData,
|
||||
// } = useQuery({
|
||||
// ...getWebSettingQuery(),
|
||||
// placeholderData: (prevData) => prevData,
|
||||
|
||||
// })
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<WebSettingUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: async() => WebSettingsService.readWebSetting()
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: WebSettingUpdate) =>
|
||||
WebSettingsService.updateWebSetting({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Settings updated successfully.", "success")
|
||||
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["webSetting"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<WebSettingUpdate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<Container maxW="full" mt="20" as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
<FormControl isInvalid={!!errors.address}>
|
||||
<FormLabel htmlFor="address">Address</FormLabel>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
{...register("address", {
|
||||
required: "address is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
{errors.address && (
|
||||
<FormErrorMessage>{errors.address.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="google_map_api_key">Google Map Api Key</FormLabel>
|
||||
<Input
|
||||
id="google_map_api_key"
|
||||
type="text"
|
||||
{...register("google_map_api_key", {
|
||||
required: "google_map_api_key is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="latitude">Latitude</FormLabel>
|
||||
<Input
|
||||
id="latitude"
|
||||
type="float"
|
||||
{...register("latitude", {
|
||||
required: "latitude is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="longitude">Longitude</FormLabel>
|
||||
<Input
|
||||
id="longitude"
|
||||
type="float"
|
||||
{...register("longitude", {
|
||||
required: "longitude is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="phone">Phone</FormLabel>
|
||||
<Input
|
||||
id="phone"
|
||||
type="text"
|
||||
{...register("phone", {
|
||||
required: "phone is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
{...register("email", {
|
||||
required: "email is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="facebook">Facebook</FormLabel>
|
||||
<Input
|
||||
id="facebook"
|
||||
type="text"
|
||||
{...register("facebook", {
|
||||
required: "facebook is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="instagram">Instagram</FormLabel>
|
||||
<Input
|
||||
id="instagram"
|
||||
type="text"
|
||||
{...register("instagram", {
|
||||
required: "instagram is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="youtube">Youtube Channel</FormLabel>
|
||||
<Input
|
||||
id="youtube"
|
||||
type="text"
|
||||
{...register("youtube", {
|
||||
required: "youtube is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="youtube_link">Youtube Video Link</FormLabel>
|
||||
<Input
|
||||
id="youtube_link"
|
||||
type="text"
|
||||
{...register("youtube_link", {
|
||||
required: "youtube_link is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="whatsapp">Whatsapp</FormLabel>
|
||||
<Input
|
||||
id="whatsapp"
|
||||
type="text"
|
||||
{...register("whatsapp", {
|
||||
required: "whatsapp is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
|
||||
</Container>
|
||||
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function WebSetting() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
|
||||
<WebSettingForms />
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
144
frontend/src/routes/login.tsx
Normal file
144
frontend/src/routes/login.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons"
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Icon,
|
||||
Image,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Link,
|
||||
Text,
|
||||
useBoolean,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import Logo from "/assets/images/logo-15.png"
|
||||
import type { Body_login_login_access_token as AccessToken } from "../client"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { emailPattern } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: Login,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function Login() {
|
||||
const [show, setShow] = useBoolean()
|
||||
const { loginMutation, error, resetError } = useAuth()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AccessToken>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<AccessToken> = async (data) => {
|
||||
if (isSubmitting) return
|
||||
|
||||
resetError()
|
||||
|
||||
try {
|
||||
await loginMutation.mutateAsync(data)
|
||||
} catch {
|
||||
// error is handled by useAuth hook
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
src={Logo}
|
||||
alt="FastAPI logo"
|
||||
height="auto"
|
||||
maxW="2xs"
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="username" isInvalid={!!errors.username || !!error}>
|
||||
<Input
|
||||
id="username"
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
{errors.username && (
|
||||
<FormErrorMessage>{errors.username.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!error}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
})}
|
||||
type={show ? "text" : "password"}
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<InputRightElement
|
||||
color="ui.dim"
|
||||
_hover={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
as={show ? ViewOffIcon : ViewIcon}
|
||||
onClick={setShow.toggle}
|
||||
aria-label={show ? "Hide password" : "Show password"}
|
||||
>
|
||||
{show ? <ViewOffIcon /> : <ViewIcon />}
|
||||
</Icon>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{error && <FormErrorMessage>{error}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
{/* <Link as={RouterLink} to="/recover-password" color="blue.500">
|
||||
Forgot password?
|
||||
</Link> */}
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Log In
|
||||
</Button>
|
||||
{/* <Text>
|
||||
Don't have an account?{" "}
|
||||
<Link as={RouterLink} to="/signup" color="blue.500">
|
||||
Sign up
|
||||
</Link>
|
||||
</Text> */}
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
104
frontend/src/routes/recover-password.tsx
Normal file
104
frontend/src/routes/recover-password.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, LoginService } from "../client"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
import useCustomToast from "../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../utils"
|
||||
|
||||
interface FormData {
|
||||
email: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/recover-password")({
|
||||
component: RecoverPassword,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function RecoverPassword() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormData>()
|
||||
const showToast = useCustomToast()
|
||||
|
||||
const recoverPassword = async (data: FormData) => {
|
||||
await LoginService.recoverPassword({
|
||||
email: data.email,
|
||||
})
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: recoverPassword,
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Email sent.",
|
||||
"We sent an email with a link to get back into your account.",
|
||||
"success",
|
||||
)
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<FormData> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
|
||||
Password Recovery
|
||||
</Heading>
|
||||
<Text align="center">
|
||||
A password recovery email will be sent to the registered account.
|
||||
</Text>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
122
frontend/src/routes/reset-password.tsx
Normal file
122
frontend/src/routes/reset-password.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, LoginService, type NewPassword } from "../client"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
import useCustomToast from "../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../utils"
|
||||
|
||||
interface NewPasswordForm extends NewPassword {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/reset-password")({
|
||||
component: ResetPassword,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function ResetPassword() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<NewPasswordForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
new_password: "",
|
||||
},
|
||||
})
|
||||
const showToast = useCustomToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const resetPassword = async (data: NewPassword) => {
|
||||
const token = new URLSearchParams(window.location.search).get("token")
|
||||
if (!token) return
|
||||
await LoginService.resetPassword({
|
||||
requestBody: { new_password: data.new_password, token: token },
|
||||
})
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: resetPassword,
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
reset()
|
||||
navigate({ to: "/login" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<NewPasswordForm> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
|
||||
Reset Password
|
||||
</Heading>
|
||||
<Text textAlign="center">
|
||||
Please enter your new password and confirm it to reset your password.
|
||||
</Text>
|
||||
<FormControl mt={4} isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>{errors.confirm_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit">
|
||||
Reset Password
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
164
frontend/src/routes/signup.tsx
Normal file
164
frontend/src/routes/signup.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Image,
|
||||
Input,
|
||||
Link,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
import type { UserRegister } from "../client"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { confirmPasswordRules, emailPattern, passwordRules } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
component: SignUp,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
interface UserRegisterForm extends UserRegister {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
function SignUp() {
|
||||
const { signUpMutation } = useAuth()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserRegisterForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
email: "",
|
||||
full_name: "",
|
||||
password: "",
|
||||
confirm_password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserRegisterForm> = (data) => {
|
||||
signUpMutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex flexDir={{ base: "column", md: "row" }} justify="center" h="100vh">
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
src={Logo}
|
||||
alt="FastAPI logo"
|
||||
height="auto"
|
||||
maxW="2xs"
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="full_name" isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="full_name" srOnly>
|
||||
Full Name
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="full_name"
|
||||
minLength={3}
|
||||
{...register("full_name", { required: "Full Name is required" })}
|
||||
placeholder="Full Name"
|
||||
type="text"
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="email" isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="username" srOnly>
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password" srOnly>
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
id="confirm_password"
|
||||
isInvalid={!!errors.confirm_password}
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password" srOnly>
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Repeat Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
<Text>
|
||||
Already have an account?{" "}
|
||||
<Link as={RouterLink} to="/login" color="blue.500">
|
||||
Log In
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignUp
|
Reference in New Issue
Block a user