+ {/* OVERLAY cho Mobile */}
+
setOpenMenuMobile(false)}
+ />
+
+ {/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
+
+
+ {/* HEADER */}
+
+
+ {/* MAIN CONTENT */}
+
+ {children}
+
+
+
+ //
+ );
+};
+
+export default BaseLayout;
diff --git a/src/components/layouts/BaseLayout/componets/Header/Header.tsx b/src/components/layouts/BaseLayout/componets/Header/Header.tsx
new file mode 100644
index 0000000..3d3f3d4
--- /dev/null
+++ b/src/components/layouts/BaseLayout/componets/Header/Header.tsx
@@ -0,0 +1,101 @@
+"use client";
+import React, { useContext, useEffect, useState } from "react";
+import { PropsHeader } from "./interface";
+import { usePathname } from "next/dist/client/components/navigation";
+import { useSelector } from "react-redux";
+import { RootState } from "@/redux/store";
+import { ContextBaseLayout } from "../../BaseLayout";
+import Image from "next/image";
+import icons from "@/constant/images/icons";
+import { ChevronDown } from "lucide-react";
+import clsx from "clsx";
+import MenuProfile from "../MenuProfile";
+
+const Header = ({ title, breadcrumb }: PropsHeader) => {
+ const pathname = usePathname();
+ const { infoUser } = useSelector((state: RootState) => state.user);
+ const context = useContext(ContextBaseLayout);
+
+ const [openProfile, setOpenProfile] = useState(false);
+
+ //Đóng menu mobile khi đổi route
+ useEffect(() => {
+ context.setOpenMenuMobile?.(false);
+ }, [pathname, context]);
+
+ const toggleFullScreen = () => {
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen();
+ } else {
+ document.exitFullscreen();
+ }
+ };
+
+ return (
+
+
+ {/* Nút bấm Mobile: Mở menu mobile */}
+
context?.setOpenMenuMobile?.(true)}
+ >
+
+
+ {/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */}
+
context?.setShowFull?.(!context?.showFull)}
+ >
+
+
+ {breadcrumb ? (
+ breadcrumb
+ ) : (
+
{title}
+ )}
+
+
+
+
+
+
+
+
setOpenProfile(!openProfile)}
+ >
+
+
+
+
+ {infoUser?.fullname || "User admin"}
+
+
+
+ {openProfile && (
+
+ setOpenProfile(false)} />
+
+ )}
+
+
+
+ );
+};
+
+export default Header;
diff --git a/src/components/layouts/BaseLayout/componets/Header/index.ts b/src/components/layouts/BaseLayout/componets/Header/index.ts
new file mode 100644
index 0000000..2764567
--- /dev/null
+++ b/src/components/layouts/BaseLayout/componets/Header/index.ts
@@ -0,0 +1 @@
+export { default } from "./Header";
diff --git a/src/components/layouts/BaseLayout/componets/Header/interface/index.ts b/src/components/layouts/BaseLayout/componets/Header/interface/index.ts
new file mode 100644
index 0000000..bd52b48
--- /dev/null
+++ b/src/components/layouts/BaseLayout/componets/Header/interface/index.ts
@@ -0,0 +1,4 @@
+export interface PropsHeader {
+ title: string;
+ breadcrumb?: React.ReactNode;
+}
diff --git a/src/components/layouts/BaseLayout/componets/MenuProfile/MenuProfile.tsx b/src/components/layouts/BaseLayout/componets/MenuProfile/MenuProfile.tsx
new file mode 100644
index 0000000..157fb72
--- /dev/null
+++ b/src/components/layouts/BaseLayout/componets/MenuProfile/MenuProfile.tsx
@@ -0,0 +1,120 @@
+import React, { use, useCallback, useState } from "react";
+import { PropsMenuProfile } from "./interface";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useMutation } from "@tanstack/react-query";
+import { httpRequest } from "@/services";
+import authServices from "@/services/authServices";
+import { store } from "@/redux/store";
+import { logout } from "@/redux/reducer/auth";
+import { setInfoUser } from "@/redux/reducer/user";
+import { PATH } from "@/constant/config";
+import Link from "next/link";
+import clsx from "clsx";
+import { LogOut, ShieldCog, ShieldUser } from "lucide-react";
+import CustomDialog from "@/components/customs/custom-dialog";
+
+const MenuProfile = ({ onClose }: PropsMenuProfile) => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const [openLogout, setOpenLogout] = useState(false);
+
+ /* ================== CHECK ACTIVE ================== */
+ const checkActive = useCallback(
+ (path: string) => {
+ return pathname === path;
+ },
+ [pathname],
+ );
+
+ /* ================== LOGOUT ================== */
+ const logoutMutation = useMutation({
+ mutationFn: () =>
+ httpRequest({
+ showMessageFailed: true,
+ showMessageSuccess: false,
+ http: authServices.logout(),
+ }),
+ onSuccess(data) {
+ if (data) {
+ store.dispatch(logout());
+ store.dispatch(setInfoUser(null));
+ router.push(PATH.LOGIN);
+ }
+ },
+ });
+
+ const handleLogout = () => {
+ logoutMutation.mutate();
+ };
+
+ return (
+
+ {/* PROFILE */}
+
+
+
+
Thông tin cá nhân
+
Chi tiết tài khoản
+
+
+
+
+ {/* CHANGE PASSWORD */}
+
+
+
+
Đổi mật khẩu
+
Thay đổi mật khẩu
+
+
+
+
+ {/* LOGOUT */}
+
{
+ setOpenLogout(true);
+ onClose();
+ }}
+ className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition"
+ >
+
+
+
Đăng xuất
+
Đăng xuất khỏi hệ thống
+
+
+
+ {/* DIALOG */}
+
setOpenLogout(false)}
+ onSubmit={handleLogout}
+ title="Đăng xuất"
+ note="Bạn có muốn đăng xuất khỏi hệ thống không?"
+ titleCancel="Không"
+ titleSubmit="Đăng xuất"
+ type="error"
+ />
+
+ );
+};
+
+export default MenuProfile;
diff --git a/src/components/layouts/BaseLayout/componets/MenuProfile/index.ts b/src/components/layouts/BaseLayout/componets/MenuProfile/index.ts
new file mode 100644
index 0000000..12ae6c8
--- /dev/null
+++ b/src/components/layouts/BaseLayout/componets/MenuProfile/index.ts
@@ -0,0 +1 @@
+export { default } from "./MenuProfile";
diff --git a/src/components/layouts/BaseLayout/componets/MenuProfile/interface/index.ts b/src/components/layouts/BaseLayout/componets/MenuProfile/interface/index.ts
new file mode 100644
index 0000000..88f466b
--- /dev/null
+++ b/src/components/layouts/BaseLayout/componets/MenuProfile/interface/index.ts
@@ -0,0 +1,3 @@
+export interface PropsMenuProfile {
+ onClose: () => void;
+}
diff --git a/src/components/layouts/BaseLayout/componets/Navbar/Navbar.tsx b/src/components/layouts/BaseLayout/componets/Navbar/Navbar.tsx
new file mode 100644
index 0000000..6733296
--- /dev/null
+++ b/src/components/layouts/BaseLayout/componets/Navbar/Navbar.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import React, { useCallback } from "react";
+import { usePathname } from "next/navigation";
+import Link from "next/link";
+import { Menus, PATH } from "@/constant/config";
+import Image from "next/image";
+import icons from "@/constant/images/icons";
+import clsx from "clsx";
+
+const Navbar = () => {
+ const pathname = usePathname();
+
+ const checkActive = useCallback(
+ (pathActive: string) => {
+ const currentRoute = pathname.split("/")[1];
+ return pathActive === `/${currentRoute}`;
+ },
+ [pathname],
+ );
+
+ return (
+
+
+
+
+ Quản lý
+
+
+
+ {Menus.map((menu, i) => (
+
+ {/* TITLE */}
+
+ {menu.title}
+
+
+
+ {menu.group.map((tab, y) => {
+ const isActive = checkActive(tab.pathActive);
+
+ return (
+
+
+
{tab.title}
+
+ );
+ })}
+
+
+ ))}
+
+
+ );
+};
+
+export default Navbar;
diff --git a/src/components/layouts/BaseLayout/componets/Navbar/index.ts b/src/components/layouts/BaseLayout/componets/Navbar/index.ts
new file mode 100644
index 0000000..8d95d66
--- /dev/null
+++ b/src/components/layouts/BaseLayout/componets/Navbar/index.ts
@@ -0,0 +1 @@
+export { default } from "./Navbar";
diff --git a/src/components/layouts/BaseLayout/index.ts b/src/components/layouts/BaseLayout/index.ts
new file mode 100644
index 0000000..cd391ad
--- /dev/null
+++ b/src/components/layouts/BaseLayout/index.ts
@@ -0,0 +1 @@
+export { default } from "./BaseLayout";
diff --git a/src/components/layouts/BaseLayout/interface/index.ts b/src/components/layouts/BaseLayout/interface/index.ts
new file mode 100644
index 0000000..637b24c
--- /dev/null
+++ b/src/components/layouts/BaseLayout/interface/index.ts
@@ -0,0 +1,12 @@
+export interface PropsBaseLayout {
+ children: React.ReactNode;
+ title: string;
+ breadcrumb?: React.ReactNode;
+}
+
+export interface TContextBaseLayout {
+ showFull?: boolean;
+ setShowFull?: (show: boolean) => void;
+ openMenuMobile?: boolean;
+ setOpenMenuMobile?: (show: boolean) => void;
+}
diff --git a/src/components/layouts/ChartWrapper/ChartWrapper.tsx b/src/components/layouts/ChartWrapper/ChartWrapper.tsx
new file mode 100644
index 0000000..2545a63
--- /dev/null
+++ b/src/components/layouts/ChartWrapper/ChartWrapper.tsx
@@ -0,0 +1,38 @@
+"use client";
+import React from "react";
+import { PropsChartWrapper } from "./interface";
+import clsx from "clsx";
+import Image from "next/image";
+import icons from "@/constant/images/icons";
+
+const ChartWrapper = ({
+ children,
+ isEmpty,
+ message = "Không có dữ liệu",
+}: PropsChartWrapper) => {
+ return (
+
+ {isEmpty ? (
+
+
+ {/* nếu muốn hiện message thì bật dòng dưới */}
+ {/*
{message}
*/}
+
+ ) : (
+ children
+ )}
+
+ );
+};
+
+export default ChartWrapper;
diff --git a/src/components/layouts/ChartWrapper/index.ts b/src/components/layouts/ChartWrapper/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/layouts/ChartWrapper/interface/index.ts b/src/components/layouts/ChartWrapper/interface/index.ts
new file mode 100644
index 0000000..acf4283
--- /dev/null
+++ b/src/components/layouts/ChartWrapper/interface/index.ts
@@ -0,0 +1,5 @@
+export interface PropsChartWrapper {
+ children: React.ReactNode;
+ isEmpty?: boolean;
+ message?: string;
+}
diff --git a/src/components/layouts/GridColumn/GridColumn.tsx b/src/components/layouts/GridColumn/GridColumn.tsx
new file mode 100644
index 0000000..68d696b
--- /dev/null
+++ b/src/components/layouts/GridColumn/GridColumn.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import React from "react";
+import { GridColumnProps } from "./interface";
+import clsx from "clsx";
+
+const GridColumn = ({
+ children,
+ col = 3,
+ sm,
+ tabletCol3,
+ mobile2,
+ scroll20,
+ scrollMobile85,
+ className,
+}: GridColumnProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default GridColumn;
diff --git a/src/components/layouts/GridColumn/index.ts b/src/components/layouts/GridColumn/index.ts
new file mode 100644
index 0000000..6b7d9a9
--- /dev/null
+++ b/src/components/layouts/GridColumn/index.ts
@@ -0,0 +1 @@
+export { default } from "./GridColumn";
diff --git a/src/components/layouts/GridColumn/interface/index.ts b/src/components/layouts/GridColumn/interface/index.ts
new file mode 100644
index 0000000..9d1c8f9
--- /dev/null
+++ b/src/components/layouts/GridColumn/interface/index.ts
@@ -0,0 +1,14 @@
+export interface GridColumnProps {
+ children: React.ReactNode;
+
+ col?: 1 | 2 | 3 | 4 | 5 | 6 | 8 | 12;
+ sm?: boolean;
+
+ tabletCol3?: boolean;
+ mobile2?: boolean;
+
+ scroll20?: boolean;
+ scrollMobile85?: boolean;
+
+ className?: string;
+}
diff --git a/src/components/layouts/LayoutAuth/LayoutAuth.tsx b/src/components/layouts/LayoutAuth/LayoutAuth.tsx
new file mode 100644
index 0000000..81f6949
--- /dev/null
+++ b/src/components/layouts/LayoutAuth/LayoutAuth.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import RequiredLogout from "@/components/protected/RequiredLogout";
+import React from "react";
+
+interface PropsLayoutAuth {
+ children: React.ReactNode;
+}
+
+const LayoutAuth = ({ children }: PropsLayoutAuth) => {
+ return (
+
+
+ {/* BACKGROUND */}
+
+
+ {/* MAIN */}
+
+ {children}
+
+
+
+ );
+};
+
+export default LayoutAuth;
diff --git a/src/components/layouts/LayoutAuth/index.ts b/src/components/layouts/LayoutAuth/index.ts
new file mode 100644
index 0000000..9756f0a
--- /dev/null
+++ b/src/components/layouts/LayoutAuth/index.ts
@@ -0,0 +1 @@
+export { default } from "./LayoutAuth";
diff --git a/src/components/layouts/LayoutPages/LayoutPages.tsx b/src/components/layouts/LayoutPages/LayoutPages.tsx
new file mode 100644
index 0000000..b263476
--- /dev/null
+++ b/src/components/layouts/LayoutPages/LayoutPages.tsx
@@ -0,0 +1,57 @@
+"use client";
+import React, { useCallback } from "react";
+import { PropsLayoutPages } from "./interface";
+import { usePathname } from "next/navigation";
+import Link from "next/link";
+import clsx from "clsx";
+
+const LayoutPages = ({ children, listPages }: PropsLayoutPages) => {
+ const pathname = usePathname();
+ const checkActive = useCallback(
+ (url: string) => {
+ return pathname === url;
+ },
+ [pathname],
+ );
+ return (
+ <>
+
+ {listPages.map((item, i) => {
+ const isActive = checkActive(item.url);
+ return (
+
{
+ if (isActive) e.preventDefault();
+ }}
+ className={clsx(
+ "inline-block min-w-[60px] text-center font-medium rounded-md px-6 py-3 border select-none cursor-pointer",
+ "text-[16px] max-[1200px]:text-[14px] max-[768px]:text-[13px]",
+ isActive
+ ? "bg-[#3772ff] text-white border-[#3772ff]"
+ : "bg-white text-[#202939] border-[#eaedf2]",
+ i !== 0 && "ml-2",
+ )}
+ >
+ {" "}
+
+
+ );
+ })}
+
+ {/* MAIN */}
+
{children}
+ >
+ );
+};
+
+export default LayoutPages;
diff --git a/src/components/layouts/LayoutPages/index.ts b/src/components/layouts/LayoutPages/index.ts
new file mode 100644
index 0000000..14b40c4
--- /dev/null
+++ b/src/components/layouts/LayoutPages/index.ts
@@ -0,0 +1 @@
+export { default } from "./LayoutPages";
diff --git a/src/components/layouts/LayoutPages/interface/index.ts b/src/components/layouts/LayoutPages/interface/index.ts
new file mode 100644
index 0000000..c47ef62
--- /dev/null
+++ b/src/components/layouts/LayoutPages/interface/index.ts
@@ -0,0 +1,8 @@
+export interface PropsLayoutPages {
+ children: React.ReactNode;
+ listPages: {
+ title: string;
+ url: string;
+ color: string;
+ }[];
+}
diff --git a/src/components/page/Home/MainPageHome/MainPageHome.tsx b/src/components/page/Home/MainPageHome/MainPageHome.tsx
new file mode 100644
index 0000000..59c3551
--- /dev/null
+++ b/src/components/page/Home/MainPageHome/MainPageHome.tsx
@@ -0,0 +1,8 @@
+"use client";
+import React from "react";
+
+const MainPageHome = () => {
+ return
MainPageHome
;
+};
+
+export default MainPageHome;
diff --git a/src/components/page/Home/MainPageHome/index.ts b/src/components/page/Home/MainPageHome/index.ts
new file mode 100644
index 0000000..310c0db
--- /dev/null
+++ b/src/components/page/Home/MainPageHome/index.ts
@@ -0,0 +1 @@
+export { default } from "./MainPageHome";
diff --git a/src/components/page/auth/MainForgotPassword/MainForgotPassword.tsx b/src/components/page/auth/MainForgotPassword/MainForgotPassword.tsx
new file mode 100644
index 0000000..8632ba1
--- /dev/null
+++ b/src/components/page/auth/MainForgotPassword/MainForgotPassword.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import React, { useState } from "react";
+
+import { IFormForgotPassword, TYPE_FORGOT_PASWORD } from "./interface";
+
+import { ContextForgotPassword } from "./context";
+
+import FormEmail from "./components/FormEmail";
+import FormPassword from "./components/FormPassword/FormPassword";
+
+export default function MainForgotPassword() {
+ const [type, setType] = useState
(
+ TYPE_FORGOT_PASWORD.EMAIL,
+ );
+
+ const [form, setForm] = useState({
+ email: "",
+ otp: "",
+ password: "",
+ rePassword: "",
+ });
+
+ return (
+
+ {/* TITLE */}
+
+ Quên mật khẩu
+
+
+ {/* DESCRIPTION */}
+
+ Nhập địa chỉ email liên kết với tài khoản của bạn để lấy lại mật khẩu!
+
+
+ {/* FORM */}
+
+
+ {type === TYPE_FORGOT_PASWORD.EMAIL && }
+
+ {type === TYPE_FORGOT_PASWORD.PASSWORD && }
+
+
+
+ );
+}
diff --git a/src/components/page/auth/MainForgotPassword/components/FormEmail/FormEmail.tsx b/src/components/page/auth/MainForgotPassword/components/FormEmail/FormEmail.tsx
new file mode 100644
index 0000000..fc2557a
--- /dev/null
+++ b/src/components/page/auth/MainForgotPassword/components/FormEmail/FormEmail.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import React, { useContext } from "react";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMutation } from "@tanstack/react-query";
+import { Mail } from "lucide-react";
+
+import FormCustom from "@/components/utils/FormCustom";
+import InputForm from "@/components/utils/FormCustom/components/InputForm";
+import CustomPopup from "@/components/customs/custom-popup";
+import CustomLoading from "@/components/customs/custom-loading";
+import CustomButton from "@/components/customs/custom-button";
+
+import { ContextForgotPassword } from "../../context";
+
+import FormOTP from "../FormOTP";
+
+import { PATH } from "@/constant/config";
+
+import { httpRequest } from "@/services";
+import accountServices from "@/services/accountServices";
+
+function FormEmail() {
+ const router = useRouter();
+
+ const searchParams = useSearchParams();
+
+ const open = searchParams.get("_open");
+
+ const { form, setForm } = useContext(ContextForgotPassword);
+
+ const sendOTPMutation = useMutation({
+ mutationFn: async () => {
+ return httpRequest({
+ showMessageFailed: true,
+ showMessageSuccess: true,
+ msgSuccess: "Mã OTP đã được gửi đến email của bạn",
+ http: accountServices.sendOTP({
+ email: form?.email,
+ }),
+ });
+ },
+
+ onSuccess(data) {
+ if (!data) return;
+
+ const params = new URLSearchParams(searchParams.toString());
+
+ params.set("_open", "otp");
+
+ router.replace(`?${params.toString()}`);
+ },
+ });
+
+ const handleSendOTP = () => {
+ sendOTPMutation.mutate();
+ };
+
+ const handleClosePopup = () => {
+ const params = new URLSearchParams(searchParams.toString());
+
+ params.delete("_open");
+
+ router.replace(`?${params.toString()}`);
+ };
+
+ return (
+
+
+
+ {/* INPUT EMAIL */}
+
+ Email
+ *
+
+ }
+ placeholder="Nhập email"
+ type="text"
+ name="email"
+ isEmail
+ onClean
+ isRequired
+ isBlur
+ icon={}
+ />
+
+ {/* BUTTON GROUP */}
+
+ {/* BUTTON SUBMIT */}
+
+ Lấy lại mật khẩu
+
+
+ {/* LINE */}
+
+
+ {/* LOGIN BUTTON */}
+
router.push(PATH.LOGIN)}
+ >
+ Đăng nhập ngay
+
+
+
+ {/* POPUP OTP */}
+
+
+
+
+ );
+}
+
+export default FormEmail;
diff --git a/src/components/page/auth/MainForgotPassword/components/FormEmail/index.ts b/src/components/page/auth/MainForgotPassword/components/FormEmail/index.ts
new file mode 100644
index 0000000..28c8163
--- /dev/null
+++ b/src/components/page/auth/MainForgotPassword/components/FormEmail/index.ts
@@ -0,0 +1 @@
+export { default } from "./FormEmail";
diff --git a/src/components/page/auth/MainForgotPassword/components/FormOTP/FormOTP.tsx b/src/components/page/auth/MainForgotPassword/components/FormOTP/FormOTP.tsx
new file mode 100644
index 0000000..09b0fb8
--- /dev/null
+++ b/src/components/page/auth/MainForgotPassword/components/FormOTP/FormOTP.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import React, { useContext, useEffect, useState } from "react";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMutation } from "@tanstack/react-query";
+
+import { X } from "lucide-react";
+
+import { ContextForgotPassword } from "../../context";
+
+import { TYPE_FORGOT_PASWORD } from "../../interface";
+
+import CustomButton from "@/components/customs/custom-button";
+import CustomLoading from "@/components/customs/custom-loading";
+
+import { httpRequest } from "@/services";
+import accountServices from "@/services/accountServices";
+import InputSingle from "@/components/customs/custom-input";
+import fancyTimeFormat, { obfuscateEmail } from "@/common/funcs/optionConvert";
+
+function FormOTP() {
+ const TIME_OTP = 60;
+
+ const router = useRouter();
+
+ const searchParams = useSearchParams();
+
+ const [countDown, setCountDown] = useState(TIME_OTP);
+
+ const { form, setForm, setType } = useContext(ContextForgotPassword);
+
+ /* ========================= COUNTDOWN ========================= */
+ useEffect(() => {
+ if (countDown <= 0) return;
+
+ const timeout = setTimeout(() => {
+ setCountDown((prev) => prev - 1);
+ }, 1000);
+
+ return () => clearTimeout(timeout);
+ }, [countDown]);
+
+ /* ========================= CLOSE POPUP ========================= */
+ const closeForm = () => {
+ const params = new URLSearchParams(searchParams.toString());
+
+ params.delete("_open");
+
+ router.replace(`?${params.toString()}`);
+ };
+
+ /* ========================= SEND OTP ========================= */
+ const sendOTPMutation = useMutation({
+ mutationFn: async () => {
+ return httpRequest({
+ showMessageFailed: true,
+ showMessageSuccess: true,
+ msgSuccess: "Mã OTP đã được gửi đến email của bạn",
+ http: accountServices.sendOTP({
+ email: form.email!,
+ }),
+ });
+ },
+
+ onSuccess(data) {
+ if (data) {
+ setCountDown(TIME_OTP);
+ }
+ },
+ });
+
+ /* ========================= VERIFY OTP ========================= */
+ const submitOTPMutation = useMutation({
+ mutationFn: async () => {
+ return httpRequest({
+ showMessageFailed: true,
+ showMessageSuccess: true,
+ msgSuccess: "Xác thực thành công",
+ http: accountServices.enterOTP({
+ email: form.email!,
+ otp: form.otp!,
+ }),
+ });
+ },
+
+ onSuccess(data) {
+ if (data) {
+ setType(TYPE_FORGOT_PASWORD.PASSWORD);
+
+ closeForm();
+ }
+ },
+ });
+
+ /* ========================= HANDLERS ========================= */
+ const handleSendCode = () => {
+ sendOTPMutation.mutate();
+ };
+
+ const handleSubmit = () => {
+ submitOTPMutation.mutate();
+ };
+
+ return (
+
+ {/* LOADING */}
+
+
+ {/* TITLE */}
+
+ Xác thực mã OTP
+
+
+ {/* DESCRIPTION */}
+
+ Một mã xác thực đã được gửi cho bạn qua địa chỉ email:
+ {obfuscateEmail(form.email!)}
+
+
+ {/* FORM */}
+
+ {/* LABEL */}
+
+ Nhập mã OTP
+
+
+ {/* INPUT OTP */}
+
+
+
+
+ {/* COUNTDOWN */}
+
+ Bạn chưa nhận được mã.
+ {countDown > 0 ? (
+
+ Gửi lại OTP ({fancyTimeFormat(countDown)})
+
+ ) : (
+
+ Gửi lại OTP
+
+ )}
+
+
+
+ {/* BUTTON */}
+
+
+ Xác thực Email
+
+
+
+ {/* CLOSE */}
+
+
+ );
+}
+
+export default FormOTP;
diff --git a/src/components/page/auth/MainForgotPassword/components/FormOTP/index.ts b/src/components/page/auth/MainForgotPassword/components/FormOTP/index.ts
new file mode 100644
index 0000000..2b61278
--- /dev/null
+++ b/src/components/page/auth/MainForgotPassword/components/FormOTP/index.ts
@@ -0,0 +1 @@
+export { default } from "./FormOTP";
diff --git a/src/components/page/auth/MainForgotPassword/components/FormPassword/FormPassword.tsx b/src/components/page/auth/MainForgotPassword/components/FormPassword/FormPassword.tsx
new file mode 100644
index 0000000..fa111f2
--- /dev/null
+++ b/src/components/page/auth/MainForgotPassword/components/FormPassword/FormPassword.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import React, { useContext } from "react";
+
+import { useRouter } from "next/navigation";
+
+import { useMutation } from "@tanstack/react-query";
+
+import { ShieldCheck } from "lucide-react";
+
+import { useSelector } from "react-redux";
+
+import md5 from "md5";
+
+import { RootState, store } from "@/redux/store";
+
+import {
+ setDataLoginStorage,
+ setStateLogin,
+ setToken,
+} from "@/redux/reducer/auth";
+
+import { setInfoUser } from "@/redux/reducer/user";
+
+import { PATH } from "@/constant/config";
+
+import { httpRequest } from "@/services";
+import authServices from "@/services/authServices";
+
+import { ContextForgotPassword, IContextForgotPassword } from "../../context";
+
+import InputForm from "@/components/utils/FormCustom/components/InputForm";
+
+import CustomButton from "@/components/customs/custom-button";
+import CustomLoading from "@/components/customs/custom-loading";
+import FormCustom from "@/components/utils/FormCustom";
+import { ContextFormCustom } from "@/components/utils/FormCustom/contexts";
+
+export default function FormPassword() {
+ const router = useRouter();
+
+ const { dataLoginStorage } = useSelector((state: RootState) => state.auth);
+
+ const { isRememberPassword } = useSelector((state: RootState) => state.site);
+
+ const { form, setForm } = useContext(
+ ContextForgotPassword,
+ );
+
+ const loginMutation = useMutation({
+ mutationFn: async () => {
+ return httpRequest({
+ showMessageFailed: true,
+ showMessageSuccess: true,
+ msgSuccess: "Đăng nhập thành công!",
+
+ http: authServices.login({
+ username: dataLoginStorage?.usernameStorage || "",
+
+ password: md5(
+ `${form.password}_${process.env.NEXT_PUBLIC_KEY_PASSWORD}`,
+ ),
+
+ ip: "",
+ address: "",
+ type: 0,
+ }),
+ });
+ },
+
+ onSuccess(data: any) {
+ if (!data) return;
+
+ store.dispatch(setStateLogin(true));
+
+ store.dispatch(setToken(data.accessToken));
+
+ store.dispatch(
+ setInfoUser({
+ accessToken: data?.accessToken || "",
+ refreshToken: data?.refreshToken || "",
+ accessExpiresAt: data?.accessExpiresAt || "",
+ refreshExpiresAt: data?.refreshExpiresAt || "",
+ avatar: data?.avatar || "",
+ fullname: data?.fullname || "",
+ }),
+ );
+
+ if (isRememberPassword) {
+ store.dispatch(
+ setDataLoginStorage({
+ usernameStorage: dataLoginStorage?.usernameStorage || "",
+
+ passwordStorage: form.password,
+ }),
+ );
+ } else {
+ store.dispatch(setDataLoginStorage(null));
+ }
+
+ router.replace(PATH.HOME);
+ },
+ });
+
+ const handleSubmit = () => {
+ loginMutation.mutate();
+ };
+
+ return (
+
+
+
+ {/* PASSWORD */}
+
+ Mật khẩu mới
+ *
+
+ }
+ placeholder="Nhập mật khẩu mới"
+ type="password"
+ name="password"
+ onClean
+ isRequired
+ isBlur
+ showDone
+ icon={}
+ />
+
+ {/* RE PASSWORD */}
+
+
+ Xác nhận mật khẩu mới
+ *
+
+ }
+ placeholder="Xác nhận mật khẩu mới"
+ type="password"
+ name="rePassword"
+ valueConfirm={form.password}
+ onClean
+ isRequired
+ isBlur
+ showDone
+ icon={}
+ />
+
+
+ {/* BUTTON */}
+
+
+ {({ isDone }) => (
+
+ Lấy lại mật khẩu
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/page/auth/MainForgotPassword/components/FormPassword/index.ts b/src/components/page/auth/MainForgotPassword/components/FormPassword/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/page/auth/MainForgotPassword/context/index.ts b/src/components/page/auth/MainForgotPassword/context/index.ts
new file mode 100644
index 0000000..8878104
--- /dev/null
+++ b/src/components/page/auth/MainForgotPassword/context/index.ts
@@ -0,0 +1,16 @@
+import { createContext, Dispatch, SetStateAction } from "react";
+import { IFormForgotPassword, TYPE_FORGOT_PASWORD } from "../interface";
+
+export interface IContextForgotPassword {
+ form: IFormForgotPassword;
+ setForm: Dispatch>;
+ type: number;
+ setType: Dispatch>;
+}
+
+export const ContextForgotPassword = createContext({
+ form: { email: "", otp: "", password: "", rePassword: "" },
+ setForm: () => null,
+ type: TYPE_FORGOT_PASWORD.EMAIL,
+ setType: () => null,
+});
diff --git a/src/components/page/auth/MainForgotPassword/index.ts b/src/components/page/auth/MainForgotPassword/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/page/auth/MainForgotPassword/interface/index.ts b/src/components/page/auth/MainForgotPassword/interface/index.ts
new file mode 100644
index 0000000..f531e95
--- /dev/null
+++ b/src/components/page/auth/MainForgotPassword/interface/index.ts
@@ -0,0 +1,11 @@
+export interface IFormForgotPassword {
+ email: string;
+ otp: string;
+ password: string;
+ rePassword: string;
+}
+
+export enum TYPE_FORGOT_PASWORD {
+ EMAIL = 1,
+ PASSWORD,
+}
diff --git a/src/components/page/auth/MainLogin/MainLogin.tsx b/src/components/page/auth/MainLogin/MainLogin.tsx
new file mode 100644
index 0000000..8e77298
--- /dev/null
+++ b/src/components/page/auth/MainLogin/MainLogin.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+
+import { useRouter } from "next/navigation";
+import { useMutation } from "@tanstack/react-query";
+import { LockKeyhole, ShieldPlus, User } from "lucide-react";
+import { useSelector } from "react-redux";
+
+import { RootState, store } from "@/redux/store";
+
+import {
+ setDataLoginStorage,
+ setStateLogin,
+ setToken,
+} from "@/redux/reducer/auth";
+
+import { setRememberPassword } from "@/redux/reducer/site";
+import { setInfoUser } from "@/redux/reducer/user";
+
+import authServices from "@/services/authServices";
+import { httpRequest } from "@/services";
+
+import { PATH } from "@/constant/config";
+
+import FormCustom from "@/components/utils/FormCustom";
+import InputForm from "@/components/utils/FormCustom/components/InputForm";
+import CustomLoading from "@/components/customs/custom-loading";
+// import FormCustom, { InputForm } from "@/components/common/FormCustom";
+
+export default function MainLogin() {
+ const router = useRouter();
+
+ const { dataLoginStorage } = useSelector((state: RootState) => state.auth);
+
+ const { isRememberPassword } = useSelector((state: RootState) => state.site);
+
+ const [form, setForm] = useState({
+ username: isRememberPassword ? dataLoginStorage?.usernameStorage || "" : "",
+
+ password: isRememberPassword ? dataLoginStorage?.passwordStorage || "" : "",
+ });
+
+ const loginMutation = useMutation({
+ mutationFn: async () => {
+ return httpRequest({
+ showMessageFailed: true,
+ showMessageSuccess: true,
+ msgSuccess: "Đăng nhập thành công!",
+ http: authServices.login({
+ username: form.username,
+ password: form.password,
+ ip: "",
+ address: "",
+ type: 0,
+ }),
+ });
+ },
+
+ onSuccess(data: any) {
+ if (!data) return;
+
+ store.dispatch(setStateLogin(true));
+
+ store.dispatch(setToken(data.accessToken));
+
+ store.dispatch(
+ setInfoUser({
+ accessToken: data?.accessToken || "",
+ refreshToken: data?.refreshToken || "",
+ accessExpiresAt: data?.accessExpiresAt || "",
+ refreshExpiresAt: data?.refreshExpiresAt || "",
+ avatar: data?.avatar || "",
+ fullname: data?.fullname || "",
+ }),
+ );
+
+ if (isRememberPassword) {
+ store.dispatch(
+ setDataLoginStorage({
+ usernameStorage: form.username,
+ passwordStorage: form.password,
+ }),
+ );
+ } else {
+ store.dispatch(setDataLoginStorage(null));
+ }
+
+ router.replace(PATH.HOME);
+ },
+ });
+
+ const handleLogin = () => {
+ loginMutation.mutate();
+ };
+
+ return (
+
+
+
+
+ {/* TITLE */}
+
+ Đăng nhập
+
+
+
+ Chào mừng bạn đến với hệ thống quản lý
+
+
+ {/* FORM */}
+
+ {/* USERNAME */}
+
+ Tài khoản
+ *
+
+ }
+ placeholder="Tài khoản"
+ type="text"
+ name="username"
+ onClean
+ isRequired
+ isBlur
+ showDone
+ icon={}
+ />
+
+ {/* PASSWORD */}
+
+
+ Mật khẩu
+ *
+
+ }
+ placeholder="Mật khẩu"
+ type="password"
+ name="password"
+ onClean
+ isRequired
+ isBlur
+ showDone
+ icon={}
+ />
+
+
+ {/* REMEMBER PASSWORD */}
+
+
+ store.dispatch(setRememberPassword(!isRememberPassword))
+ }
+ className="
+ h-5
+ w-5
+ cursor-pointer
+ accent-[#0011AB]
+ "
+ />
+
+
+
+
+ {/* BUTTONS */}
+
+ {/* LOGIN */}
+
+
+ {/* LINE */}
+
+
+ {/* FORGOT PASSWORD */}
+
+
+
+
+
+ );
+}
diff --git a/src/components/page/auth/MainLogin/index.ts b/src/components/page/auth/MainLogin/index.ts
new file mode 100644
index 0000000..efa3c86
--- /dev/null
+++ b/src/components/page/auth/MainLogin/index.ts
@@ -0,0 +1 @@
+export { default } from "./MainLogin";
diff --git a/src/components/protected/LoadingTopBar/LoadingTopBar.tsx b/src/components/protected/LoadingTopBar/LoadingTopBar.tsx
new file mode 100644
index 0000000..ea88219
--- /dev/null
+++ b/src/components/protected/LoadingTopBar/LoadingTopBar.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useEffect } from "react";
+import NProgress from "nprogress";
+import "nprogress/nprogress.css";
+
+function LoadingTopBar() {
+ useEffect(() => {
+ NProgress.configure({ showSpinner: false });
+
+ const handleClick = (e: any) => {
+ const target = e.target.closest("a");
+
+ if (target?.href && target.href.startsWith(window.location.origin)) {
+ NProgress.start();
+ }
+ };
+
+ const handleStop = () => {
+ NProgress.done();
+ };
+
+ window.addEventListener("click", handleClick);
+ window.addEventListener("load", handleStop);
+
+ return () => {
+ window.removeEventListener("click", handleClick);
+ window.removeEventListener("load", handleStop);
+ };
+ }, []);
+
+ return null;
+}
+
+export default LoadingTopBar;
diff --git a/src/components/protected/LoadingTopBar/index.ts b/src/components/protected/LoadingTopBar/index.ts
new file mode 100644
index 0000000..6219428
--- /dev/null
+++ b/src/components/protected/LoadingTopBar/index.ts
@@ -0,0 +1 @@
+export { default } from "./LoadingTopBar";
diff --git a/src/components/protected/RequiredAuth/index.tsx b/src/components/protected/RequiredAuth/index.tsx
new file mode 100644
index 0000000..011c0ed
--- /dev/null
+++ b/src/components/protected/RequiredAuth/index.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { PATH } from "@/constant/config";
+import { RootState } from "@/redux/store";
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { useSelector } from "react-redux";
+
+interface IRequireAuthProps {
+ children: React.ReactNode;
+}
+
+export default function RequireAuth(props: IRequireAuthProps) {
+ const router = useRouter();
+
+ const { loading } = useSelector((state: RootState) => state.site);
+ const { isLogin } = useSelector((state: RootState) => state.auth);
+
+ useEffect(() => {
+ if (!isLogin && !loading) {
+ router.replace(PATH.LOGIN);
+ }
+ }, [isLogin, loading, router]);
+
+ if (isLogin && !loading) {
+ return <>{props.children}>;
+ }
+
+ return ;
+}
diff --git a/src/components/protected/RequiredLogout/index.tsx b/src/components/protected/RequiredLogout/index.tsx
new file mode 100644
index 0000000..cc8b369
--- /dev/null
+++ b/src/components/protected/RequiredLogout/index.tsx
@@ -0,0 +1,34 @@
+//**********************
+//* COMPONENT PROTECTED SCREEN THEN LOGIN
+//**********************
+
+"use client";
+
+import React, { useEffect } from "react";
+
+import { RootState } from "@/redux/store";
+import { useRouter } from "next/navigation";
+import { useSelector } from "react-redux";
+import { PATH } from "@/constant/config";
+
+interface props {
+ children: React.ReactNode;
+}
+
+function RequiredLogout({ children }: props) {
+ const { replace } = useRouter();
+ const { isLogin } = useSelector((state: RootState) => state.auth);
+ const { loading } = useSelector((state: RootState) => state.site);
+
+ useEffect(() => {
+ if (isLogin && !loading) replace(PATH.HOME);
+ }, [isLogin, loading, replace]);
+
+ if (!isLogin && !loading) {
+ return <>{children}>;
+ }
+
+ return ;
+}
+
+export default RequiredLogout;
diff --git a/src/components/protected/SplashScreen/SplashScreen.tsx b/src/components/protected/SplashScreen/SplashScreen.tsx
new file mode 100644
index 0000000..a78c6a1
--- /dev/null
+++ b/src/components/protected/SplashScreen/SplashScreen.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { RootState, store } from "@/redux/store";
+import { useSelector } from "react-redux";
+import clsx from "clsx";
+import Lottie from "lottie-react";
+
+import { getItemStorage, setItemStorage } from "@/common/funcs/localStorage";
+import { KEY_STORE } from "@/constant/config";
+
+import { setLoading, setRememberPassword } from "@/redux/reducer/site";
+import {
+ setDataLoginStorage,
+ setStateLogin,
+ setToken,
+} from "@/redux/reducer/auth";
+import { setInfoUser } from "@/redux/reducer/user";
+
+import loadingAnim from "@/../public/static/anim/loading_screen.json";
+
+function SplashScreen() {
+ const [isClosing, setIsClosing] = useState(false);
+
+ const { loading, isRememberPassword } = useSelector(
+ (state: RootState) => state.site,
+ );
+ const { infoUser } = useSelector((state: RootState) => state.user);
+ const { token, isLogin, dataLoginStorage } = useSelector(
+ (state: RootState) => state.auth,
+ );
+
+ // 🔥 Load state từ localStorage
+ useEffect(() => {
+ (async () => {
+ const encryptedState = await getItemStorage(KEY_STORE);
+ const SECRET_KEY = process.env.NEXT_PUBLIC_SECRET_KEY || "default_key";
+
+ const decode = (data: string): any => {
+ try {
+ const decoded = decodeURIComponent(atob(data));
+ const clean = decoded.startsWith(SECRET_KEY)
+ ? decoded.slice(SECRET_KEY.length)
+ : null;
+ return clean ? JSON.parse(clean) : null;
+ } catch (err) {
+ console.error("Decode error:", err);
+ return null;
+ }
+ };
+
+ const state =
+ typeof encryptedState === "string" ? decode(encryptedState) : null;
+
+ if (state) {
+ store.dispatch(setToken(state.token));
+ store.dispatch(setStateLogin(state.isLogin));
+ store.dispatch(setInfoUser(state.infoUser));
+ store.dispatch(setRememberPassword(state.isRememberPassword));
+ store.dispatch(setDataLoginStorage(state.dataLoginStorage));
+ }
+
+ store.dispatch(setLoading(false));
+ })();
+ }, []);
+
+ // 🔥 Save lại state
+ useEffect(() => {
+ if (!loading) {
+ const SECRET_KEY = process.env.NEXT_PUBLIC_SECRET_KEY || "default_key";
+
+ const encode = (data: string): string => {
+ const textToEncode = SECRET_KEY + data;
+ return btoa(encodeURIComponent(textToEncode));
+ };
+
+ const hashedData = encode(
+ JSON.stringify({
+ isLogin,
+ token,
+ infoUser,
+ isRememberPassword,
+ dataLoginStorage,
+ }),
+ );
+
+ setItemStorage(KEY_STORE, hashedData);
+
+ // 👉 trigger animation fade out
+ setTimeout(() => setIsClosing(true), 0);
+ }
+ }, [loading, isLogin, token, infoUser, isRememberPassword, dataLoginStorage]);
+
+ return (
+
+ );
+}
+
+export default SplashScreen;
diff --git a/src/components/protected/SplashScreen/index.ts b/src/components/protected/SplashScreen/index.ts
new file mode 100644
index 0000000..076d132
--- /dev/null
+++ b/src/components/protected/SplashScreen/index.ts
@@ -0,0 +1 @@
+export { default } from "./SplashScreen";
diff --git a/src/components/utils/FormCustom/FormCustom.tsx b/src/components/utils/FormCustom/FormCustom.tsx
new file mode 100644
index 0000000..36c61cf
--- /dev/null
+++ b/src/components/utils/FormCustom/FormCustom.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import React, { useCallback, useMemo, useState } from "react";
+
+import { PropsFormCustom } from "./interface";
+import { ContextFormCustom } from "./contexts";
+
+function FormCustom>({
+ form,
+ setForm,
+ onSubmit,
+ children,
+}: PropsFormCustom) {
+ // 👉 tạo object error ban đầu
+ const initialError = useMemo(() => {
+ return Object.fromEntries(
+ Object.keys(form).map((key) => [key, null]),
+ ) as Record;
+ }, [form]);
+
+ const [countValidate, setCountValidate] = useState(0);
+ const [errorText, setErrorText] =
+ useState>(initialError);
+
+ const [validate, setValidate] = useState | null>(
+ null,
+ );
+
+ // 👉 check form hợp lệ
+ const isDone = useMemo(() => {
+ if (!validate) return false;
+
+ return Object.values(validate).every(Boolean);
+ }, [validate]);
+
+ // 👉 submit form
+ const handleSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (isDone) {
+ onSubmit?.();
+ } else {
+ setCountValidate((prev) => prev + 1);
+ }
+ },
+ [isDone, onSubmit],
+ );
+
+ return (
+
+
+
+ );
+}
+
+export default FormCustom;
diff --git a/src/components/utils/FormCustom/components/InputForm/InputForm.tsx b/src/components/utils/FormCustom/components/InputForm/InputForm.tsx
new file mode 100644
index 0000000..0d26197
--- /dev/null
+++ b/src/components/utils/FormCustom/components/InputForm/InputForm.tsx
@@ -0,0 +1,401 @@
+"use client";
+
+import React, { useContext, useMemo, useState } from "react";
+
+import clsx from "clsx";
+
+import { Check, CircleX, Eye, EyeOff } from "lucide-react";
+
+import { validateEmail, validatePhoneNumber } from "@/common/funcs/validate";
+
+import { convertCoin, price } from "@/common/funcs/convertCoin";
+
+import { ContextFormCustom, IContextFormCustom } from "../../contexts";
+
+import { PropsInputForm } from "./interface";
+
+function InputForm({
+ type,
+ name,
+ placeholder,
+ icon,
+ label,
+ value,
+ unit,
+ note,
+ textRequired,
+ textConfirm,
+ valueConfirm,
+ isBlur = true,
+ onClean,
+ showDone,
+ readOnly,
+ max,
+ min,
+ isRequired,
+ isNumber,
+ isUppercase,
+ isPhone,
+ isEmail,
+ isMoney,
+ onBlur,
+ onChangeValue,
+}: PropsInputForm) {
+ const isPassword = type === "password";
+
+ const [showPass, setShowPass] = useState(false);
+
+ const [isFocus, setIsFocus] = useState(false);
+
+ const {
+ form,
+ countValidate,
+ errorText,
+ isDone,
+ setForm,
+ setValidate,
+ setErrorText,
+ } = useContext>(ContextFormCustom);
+
+ // =========================
+ // VALUE
+ // =========================
+
+ const currentValue = useMemo(() => {
+ return value ?? form?.[name] ?? "";
+ }, [value, form, name]);
+
+ // =========================
+ // VALIDATE
+ // =========================
+
+ const handleValidate = (inputValue?: string | number) => {
+ const finalValue = String(inputValue ?? currentValue ?? "");
+
+ if (isRequired && finalValue.trim() === "") {
+ return {
+ valid: false,
+ message: textRequired || "Vui lòng nhập trường này",
+ };
+ }
+
+ if (finalValue) {
+ if (isNumber && isNaN(Number(finalValue))) {
+ return {
+ valid: false,
+ message: "Vui lòng chỉ nhập số",
+ };
+ }
+
+ if (isPhone && !validatePhoneNumber(finalValue)) {
+ return {
+ valid: false,
+ message: "Định dạng số điện thoại không đúng",
+ };
+ }
+
+ if (isEmail && !validateEmail(finalValue)) {
+ return {
+ valid: false,
+ message: "Định dạng email không chính xác",
+ };
+ }
+
+ if (valueConfirm && finalValue !== valueConfirm) {
+ return {
+ valid: false,
+ message: textConfirm || "Mật khẩu không trùng khớp",
+ };
+ }
+
+ if (max && finalValue.length > max) {
+ return {
+ valid: false,
+ message: `Nhập tối đa ${max} kí tự`,
+ };
+ }
+
+ if (min && finalValue.length < min) {
+ return {
+ valid: false,
+ message: `Nhập tối thiểu ${min} kí tự`,
+ };
+ }
+ }
+
+ return {
+ valid: true,
+ message: null,
+ };
+ };
+
+ // =========================
+ // ERROR
+ // =========================
+
+ const handleSetMessage = (inputValue?: string | number) => {
+ const result = handleValidate(inputValue);
+
+ setErrorText((prev) => ({
+ ...prev,
+ [name]: result.message,
+ }));
+
+ setValidate((prev) => ({
+ ...prev,
+ [name]: result.valid,
+ }));
+ };
+
+ // =========================
+ // HANDLERS
+ // =========================
+
+ const handleFocus = () => {
+ setIsFocus(true);
+
+ setErrorText((prev) => ({
+ ...prev,
+ [name]: null,
+ }));
+ };
+
+ const handleBlur = () => {
+ setIsFocus(false);
+
+ onBlur?.();
+
+ if (isBlur) {
+ handleSetMessage();
+ }
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ let inputValue = e.target.value;
+
+ if (isUppercase) {
+ inputValue = inputValue.toUpperCase();
+ }
+
+ // MONEY
+ if (isMoney) {
+ const numericValue = Number(price(inputValue));
+
+ inputValue = numericValue ? convertCoin(numericValue) : "0";
+ }
+
+ // CUSTOM CHANGE
+ if (onChangeValue) {
+ onChangeValue(inputValue);
+ return;
+ }
+
+ // UPDATE FORM
+ setForm((prev: any) => ({
+ ...prev,
+ [name]: inputValue,
+ }));
+
+ // VALIDATE LIVE
+ if (countValidate > 0) {
+ handleSetMessage(inputValue);
+ }
+ };
+
+ const handleClean = () => {
+ setForm((prev: any) => ({
+ ...prev,
+ [name]: "",
+ }));
+
+ setErrorText((prev) => ({
+ ...prev,
+ [name]: null,
+ }));
+
+ setValidate((prev) => ({
+ ...prev,
+ [name]: !isRequired,
+ }));
+ };
+
+ // =========================
+ // RENDER
+ // =========================
+
+ return (
+
+ {/* LABEL */}
+ {label && (
+
+ )}
+
+ {/* INPUT WRAPPER */}
+
+ {/* ICON */}
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {/* INPUT */}
+
+
+ {/* UNIT */}
+ {unit ? (
+
+ {unit}
+
+ ) : (
+
+ {/* CLEAN */}
+ {onClean && !!currentValue && (
+
+ )}
+
+ {/* DONE */}
+ {!isPassword &&
+ showDone &&
+ isDone &&
+ !errorText?.[name] &&
+ !!currentValue && }
+
+ {/* PASSWORD */}
+ {isPassword && (
+
+ )}
+
+ )}
+
+
+ {/* ERROR */}
+ {errorText?.[name] && (
+
+ {errorText[name]}
+
+ )}
+
+ {/* NOTE */}
+ {note && (
+
+ {note}
+
+ )}
+
+ );
+}
+
+export default InputForm;
diff --git a/src/components/utils/FormCustom/components/InputForm/index.ts b/src/components/utils/FormCustom/components/InputForm/index.ts
new file mode 100644
index 0000000..2d6bc1c
--- /dev/null
+++ b/src/components/utils/FormCustom/components/InputForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./InputForm";
diff --git a/src/components/utils/FormCustom/components/InputForm/interface/index.ts b/src/components/utils/FormCustom/components/InputForm/interface/index.ts
new file mode 100644
index 0000000..7942454
--- /dev/null
+++ b/src/components/utils/FormCustom/components/InputForm/interface/index.ts
@@ -0,0 +1,35 @@
+import { Dispatch, SetStateAction } from "react";
+
+export interface PropsInputForm {
+ type: string;
+ name: string;
+ placeholder: string;
+
+ icon?: React.ReactNode;
+ label?: string | React.ReactNode;
+
+ value?: string | number;
+ unit?: string;
+ note?: string;
+ textRequired?: string;
+ valueConfirm?: string;
+ textConfirm?: string;
+
+ isBlur?: boolean;
+ onClean?: boolean;
+ showDone?: boolean;
+ readOnly?: boolean;
+
+ max?: number;
+ min?: number;
+
+ isRequired?: boolean;
+ isNumber?: boolean;
+ isPhone?: boolean;
+ isEmail?: boolean;
+ isMoney?: boolean;
+ isUppercase?: boolean;
+
+ onBlur?: () => void;
+ onChangeValue?: (val: string | number) => void;
+}
diff --git a/src/components/utils/FormCustom/components/SelectForm/SelectForm.tsx b/src/components/utils/FormCustom/components/SelectForm/SelectForm.tsx
new file mode 100644
index 0000000..791eb36
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectForm/SelectForm.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import clsx from "clsx";
+import { ChevronDown, Search, CircleX } from "lucide-react";
+import Image from "next/image";
+
+import { removeVietnameseTones } from "@/common/funcs/optionConvert";
+import icons from "@/constant/images/icons";
+import { PropsSelectForm } from "./interface";
+
+function SelectForm({
+ label,
+ placeholder = "Chọn...",
+ isSearch = true,
+ readOnly,
+ value,
+ options = [], // ✅ FIX NULL
+ onClean,
+ onSelect,
+ getOptionLabel,
+ getOptionValue,
+}: PropsSelectForm) {
+ const refWrapper = useRef(null);
+ const refInput = useRef(null);
+
+ const [open, setOpen] = useState(false);
+ const [keyword, setKeyword] = useState("");
+
+ /* ================= CLICK OUTSIDE ================= */
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (!refWrapper.current?.contains(e.target as Node)) {
+ setOpen(false);
+ setKeyword("");
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ /* ================= FOCUS SEARCH ================= */
+ useEffect(() => {
+ if (open && isSearch) {
+ setTimeout(() => {
+ refInput.current?.focus();
+ }, 0);
+ }
+ }, [open, isSearch]);
+
+ /* ================= DATA ================= */
+ const safeOptions = options ?? [];
+
+ const selectedOption = safeOptions.find(
+ (opt) => getOptionValue(opt) === value,
+ );
+
+ const filteredOptions = safeOptions.filter((opt) => {
+ const label = removeVietnameseTones(getOptionLabel(opt) || "");
+ const key = removeVietnameseTones(keyword || "");
+ return label.includes(key);
+ });
+
+ /* ================= HANDLER ================= */
+ const handleSelect = (opt: OptionType) => {
+ onSelect?.(opt);
+ setOpen(false);
+ setKeyword("");
+ };
+
+ return (
+
+ {/* LABEL */}
+ {label && (
+
+ )}
+
+ {/* SELECT BOX */}
+
!readOnly && setOpen((prev) => !prev)}
+ className={clsx(
+ "flex items-center justify-between px-4 h-12 border rounded-full cursor-pointer transition",
+ "border-gray-300 bg-white hover:border-blue-600",
+ open && "border-blue-600",
+ readOnly &&
+ "bg-gray-100 border-gray-200 cursor-not-allowed hover:border-gray-200",
+ )}
+ >
+ {/* VALUE */}
+
+ {selectedOption ? getOptionLabel(selectedOption) : placeholder}
+
+
+ {/* CLEAR */}
+ {onClean && !!value && !readOnly && (
+
{
+ e.stopPropagation();
+ onClean();
+ }}
+ className="mr-2 hover:opacity-70"
+ >
+
+
+ )}
+
+ {/* ARROW */}
+
+
+
+ {/* DROPDOWN */}
+ {open && (
+
+ {/* SEARCH */}
+ {isSearch && (
+
+
+ setKeyword(e.target.value)}
+ className="flex-1 bg-transparent outline-none text-sm"
+ />
+
+ )}
+
+ {/* LIST */}
+ {filteredOptions.length > 0 ? (
+
+ {filteredOptions.map((opt) => {
+ const active = getOptionValue(opt) === value;
+
+ return (
+
handleSelect(opt)}
+ className={clsx(
+ "px-4 py-2 text-sm font-medium rounded-2xl cursor-pointer transition",
+ active
+ ? "bg-blue-700 text-white"
+ : "hover:bg-blue-700 hover:text-white",
+ )}
+ >
+ {getOptionLabel(opt)}
+
+ );
+ })}
+
+ ) : (
+
+
+
Danh sách lựa chọn rỗng!
+
+ )}
+
+ )}
+
+ );
+}
+
+export default SelectForm;
diff --git a/src/components/utils/FormCustom/components/SelectForm/index.ts b/src/components/utils/FormCustom/components/SelectForm/index.ts
new file mode 100644
index 0000000..0470c2e
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./SelectForm";
diff --git a/src/components/utils/FormCustom/components/SelectForm/interface/index.ts b/src/components/utils/FormCustom/components/SelectForm/interface/index.ts
new file mode 100644
index 0000000..d674375
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectForm/interface/index.ts
@@ -0,0 +1,15 @@
+export interface PropsSelectForm {
+ placeholder: string;
+ label?: string | React.ReactNode;
+
+ isSearch?: boolean;
+ readOnly?: boolean;
+
+ value: string | number;
+ options: OptionType[];
+
+ onClean?: () => void;
+ onSelect: (option: OptionType) => void;
+ getOptionLabel: (option: OptionType) => string;
+ getOptionValue: (option: OptionType) => string | number;
+}
diff --git a/src/components/utils/FormCustom/components/SelectMany/SelectMany.tsx b/src/components/utils/FormCustom/components/SelectMany/SelectMany.tsx
new file mode 100644
index 0000000..1d4cec2
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectMany/SelectMany.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import React, { Fragment, useEffect, useRef, useState } from "react";
+import clsx from "clsx";
+import { CirclePlus, Trash, Search, X } from "lucide-react";
+import { removeVietnameseTones } from "@/common/funcs/optionConvert";
+import Image from "next/image";
+import icons from "@/constant/images/icons";
+import Popup from "@/components/customs/custom-popup";
+import CustomButton from "@/components/customs/custom-button";
+
+import { PropsSelectMany } from "./interface";
+
+function SelectMany({
+ text,
+ label,
+ placeholder,
+ isSearch = true,
+ showSelectedItems = false,
+ readOnly,
+ disabledItems = [],
+ selectedItems = [],
+ options = [],
+ title,
+ onClickSelect,
+ setSelectedItems,
+ getOptionLabel,
+ getOptionValue,
+ onRemove,
+ action,
+ renderOption,
+ selectedItemFields,
+ selectedItemFieldLabels,
+}: PropsSelectMany) {
+ const refInputSearch = useRef(null);
+
+ const [keyword, setKeyword] = useState("");
+ const [isFocus, setIsFocus] = useState(false);
+ const [listDataTerm, setListDataTerm] =
+ useState>(selectedItems);
+
+ useEffect(() => {
+ if (isFocus) {
+ setTimeout(() => refInputSearch.current?.focus(), 0);
+ }
+ }, [isFocus]);
+
+ const filteredOptions = options.filter((opt) =>
+ removeVietnameseTones(String(getOptionLabel(opt) || "")).includes(
+ removeVietnameseTones(keyword),
+ ),
+ );
+
+ const handleOptionClick = (option: OptionType) => {
+ const value = getOptionValue(option);
+ if (disabledItems.includes(value)) return;
+
+ setListDataTerm((prev) =>
+ prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
+ );
+
+ setKeyword("");
+ };
+
+ const handleRemoveSelected = (value: string | number) => {
+ const updated = listDataTerm.filter((v) => v !== value);
+ setListDataTerm(updated);
+
+ onRemove ? onRemove(value) : setSelectedItems?.(updated);
+ };
+
+ const handleClose = () => {
+ setKeyword("");
+ setIsFocus(false);
+
+ // reset lại data cũ
+ setTimeout(() => {
+ setListDataTerm(selectedItems);
+ }, 0);
+ };
+
+ const handleSaveData = () => {
+ setIsFocus(false);
+ setSelectedItems?.(listDataTerm);
+ };
+
+ return (
+
+
+ {label && (
+
+ )}
+
+ {/* Select box */}
+
!readOnly && setIsFocus(true)}
+ className={clsx(
+ "flex items-center justify-between h-12 px-4 rounded-full border cursor-pointer transition",
+ "border-gray-300 bg-white hover:border-blue-700",
+ isFocus && "border-blue-700",
+ readOnly && "bg-gray-100 border-gray-200 cursor-not-allowed",
+ )}
+ >
+
+ {selectedItems.length > 0
+ ? `Đã chọn ${selectedItems.length} ${text}`
+ : placeholder}
+
+
+
+
+
+ {/* Selected chips */}
+ {showSelectedItems === "default" && (
+
+ {selectedItems
+ .map((v) => options.find((o) => getOptionValue(o) === v))
+ .filter(Boolean)
+ .map((item) => {
+ const value = getOptionValue(item!);
+ const disabled = disabledItems.includes(value);
+
+ return (
+
+ {getOptionLabel(item!)}
+ handleRemoveSelected(value)}
+ />
+
+ );
+ })}
+
+ )}
+
+
+ {/* Popup */}
+
+
+ {/* Title */}
+
{title || "Danh sách"}
+
+ {/* Search */}
+ {isSearch && (
+
+
+ setKeyword(e.target.value)}
+ placeholder="Tìm kiếm..."
+ className="flex-1 outline-none bg-transparent text-sm"
+ />
+
+ )}
+
+ {/* Select all */}
+ {filteredOptions.length > 0 && (
+
+
+
+ setListDataTerm(
+ e.target.checked ? options.map(getOptionValue) : [],
+ )
+ }
+ className="w-5 h-5 accent-blue-500"
+ />
+ Chọn tất cả
+
+
+
+ Đã chọn:{" "}
+ {listDataTerm.length}
+
+
+ )}
+
+ {/* Options */}
+ {filteredOptions.length > 0 ? (
+
+ {filteredOptions.map((opt) => {
+ const value = getOptionValue(opt);
+ const checked = listDataTerm.includes(value);
+
+ return (
+
+ handleOptionClick(opt)}
+ disabled={disabledItems.includes(value)}
+ className="w-5 h-5 accent-blue-500"
+ />
+
+
+ );
+ })}
+
+ ) : (
+
+
+
Danh sách lựa chọn rỗng!
+
+ )}
+
+ {/* Buttons */}
+
+
+ Hủy bỏ
+
+
+
+ Xác nhận
+
+
+
+ {/* Close */}
+
+
+
+
+
+
+ );
+}
+
+export default SelectMany;
diff --git a/src/components/utils/FormCustom/components/SelectMany/index.ts b/src/components/utils/FormCustom/components/SelectMany/index.ts
new file mode 100644
index 0000000..ed5c741
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectMany/index.ts
@@ -0,0 +1 @@
+export { default } from "./SelectMany";
diff --git a/src/components/utils/FormCustom/components/SelectMany/interface/index.ts b/src/components/utils/FormCustom/components/SelectMany/interface/index.ts
new file mode 100644
index 0000000..35749c3
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectMany/interface/index.ts
@@ -0,0 +1,27 @@
+import { Dispatch, SetStateAction } from "react";
+
+export interface PropsSelectMany {
+ text: string;
+ placeholder: string;
+ label?: string | React.ReactNode;
+
+ isSearch?: boolean;
+ readOnly?: boolean;
+ showSelectedItems?: false | "default" | "input";
+
+ disabledItems?: Array;
+ selectedItems: Array;
+ options: OptionType[];
+ title?: string;
+
+ onClickSelect?: () => void;
+ setSelectedItems?: Dispatch>>;
+ getOptionLabel: (option: OptionType) => string | React.ReactNode;
+ getOptionValue: (option: OptionType) => string | number;
+ onRemove?: (item: string | number) => void;
+
+ action?: React.ReactNode;
+ renderOption?: (option: OptionType) => React.ReactNode;
+ selectedItemFields?: (keyof OptionType)[];
+ selectedItemFieldLabels?: Record;
+}
diff --git a/src/components/utils/FormCustom/components/SelectSingle/SelectSingle.tsx b/src/components/utils/FormCustom/components/SelectSingle/SelectSingle.tsx
new file mode 100644
index 0000000..60033ef
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectSingle/SelectSingle.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import clsx from "clsx";
+import { X, Search } from "lucide-react";
+
+import Image from "next/image";
+
+import { removeVietnameseTones } from "@/common/funcs/optionConvert";
+import icons from "@/constant/images/icons";
+import Popup from "@/components/customs/custom-popup";
+import CustomButton from "@/components/customs/custom-button";
+import { PropsSelectSingle } from "./interface";
+
+function SelectSingle({
+ text = "",
+ label,
+ placeholder = "Chọn 1 giá trị",
+ isSearch = true,
+ readOnly,
+ disabledItems = [],
+ selectedItem,
+ options,
+ title,
+ action,
+ getOptionLabel,
+ getOptionValue,
+ setSelectedItem,
+ onClickSelect,
+ renderOption,
+}: PropsSelectSingle) {
+ const refInputSearch = useRef(null);
+
+ const [keyword, setKeyword] = useState("");
+ const [isFocus, setIsFocus] = useState(false);
+ const [selectedTemp, setSelectedTemp] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ if (isFocus && refInputSearch.current) {
+ setTimeout(() => refInputSearch.current?.focus(), 0);
+ }
+ }, [isFocus]);
+
+ const handlerFocused = () => {
+ if (onClickSelect) {
+ onClickSelect();
+ return;
+ }
+
+ if (readOnly) return;
+
+ // sync dữ liệu tại đây
+ setSelectedTemp(selectedItem ?? null);
+
+ setIsFocus(true);
+ };
+
+ const handleSelect = (value: string | number) => {
+ if (!disabledItems.includes(value)) {
+ setSelectedTemp(value);
+ }
+ };
+
+ const handleSave = () => {
+ setSelectedItem?.(selectedTemp);
+ setIsFocus(false);
+ };
+
+ const handleClose = () => {
+ setIsFocus(false);
+ setKeyword("");
+ setSelectedTemp(selectedItem ?? null);
+ };
+
+ const selectedLabel = (() => {
+ const selectedOption = options.find(
+ (opt) => getOptionValue(opt) === selectedItem,
+ );
+ return selectedOption
+ ? getOptionLabel(selectedOption)
+ : `Đã chọn 1 ${text}`;
+ })();
+
+ const filteredOptions = options.filter((opt) =>
+ removeVietnameseTones(getOptionLabel(opt)).includes(
+ removeVietnameseTones(keyword),
+ ),
+ );
+
+ return (
+
+ {label && (
+
+ )}
+
+ {/* Select box */}
+
+
+ {selectedItem ? selectedLabel : placeholder}
+
+
+
+ {/* Popup */}
+
+
+ {/* Header */}
+
+
+ {title || "Chọn 1 giá trị"}
+
+
+ {isSearch && (
+
+
+
+ setKeyword(e.target.value)}
+ placeholder="Tìm kiếm..."
+ className="flex-1 ml-2 outline-none bg-transparent"
+ />
+
+
+ {action &&
{action}
}
+
+ )}
+
+
+ {/* List */}
+ {filteredOptions.length > 0 ? (
+
+ {filteredOptions.map((opt) => {
+ const value = getOptionValue(opt);
+ return (
+
+ handleSelect(value)}
+ disabled={disabledItems.includes(value)}
+ className="w-5 h-5 accent-[#0011AB]"
+ />
+
+
+ );
+ })}
+
+ ) : (
+
+
+
Danh sách lựa chọn rỗng!
+
+ )}
+
+ {/* Footer */}
+
+
+ Hủy bỏ
+
+
+
+ Xác nhận
+
+
+
+ {/* Close */}
+
+
+
+
+
+
+ );
+}
+
+export default SelectSingle;
diff --git a/src/components/utils/FormCustom/components/SelectSingle/index.ts b/src/components/utils/FormCustom/components/SelectSingle/index.ts
new file mode 100644
index 0000000..5e9f46e
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectSingle/index.ts
@@ -0,0 +1 @@
+export { default } from "./SelectSingle";
diff --git a/src/components/utils/FormCustom/components/SelectSingle/interface/index.ts b/src/components/utils/FormCustom/components/SelectSingle/interface/index.ts
new file mode 100644
index 0000000..99c80ab
--- /dev/null
+++ b/src/components/utils/FormCustom/components/SelectSingle/interface/index.ts
@@ -0,0 +1,17 @@
+export interface PropsSelectSingle {
+ text?: string;
+ label?: React.ReactNode;
+ placeholder?: string;
+ isSearch?: boolean;
+ readOnly?: boolean;
+ disabledItems?: (string | number)[];
+ selectedItem?: string | number | null;
+ options: OptionType[];
+ title?: string;
+ action?: React.ReactNode;
+ getOptionLabel: (opt: OptionType) => string;
+ getOptionValue: (opt: OptionType) => string | number;
+ setSelectedItem?: (value: string | number | null) => void;
+ renderOption?: (option: OptionType) => React.ReactNode;
+ onClickSelect?: () => void;
+}
diff --git a/src/components/utils/FormCustom/components/TextArea/TextArea.tsx b/src/components/utils/FormCustom/components/TextArea/TextArea.tsx
new file mode 100644
index 0000000..85f19d2
--- /dev/null
+++ b/src/components/utils/FormCustom/components/TextArea/TextArea.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import React, { useContext, useEffect, useState } from "react";
+import clsx from "clsx";
+
+import { PropsTextArea } from "./interface";
+import { ContextFormCustom, IContextFormCustom } from "../../contexts";
+
+function TextArea({
+ name,
+ value,
+ placeholder,
+ label,
+ isBlur = true,
+ showDone,
+ readOnly,
+ max,
+ min,
+ isRequired,
+ textRequired,
+}: PropsTextArea) {
+ const [isFocus, setIsFocus] = useState(false);
+
+ const {
+ form,
+ countValidate,
+ errorText,
+ isDone,
+ setForm,
+ setValidate,
+ setErrorText,
+ } = useContext>(ContextFormCustom);
+
+ // =========================
+ // VALIDATE
+ // =========================
+
+ const handleValidate = (): boolean => {
+ const currentValue = `${form[name] || ""}`.trim();
+
+ if (isRequired && currentValue === "") return false;
+
+ if (min && currentValue.length < min) return false;
+
+ if (max && currentValue.length > max) return false;
+
+ return true;
+ };
+
+ // =========================
+ // ERROR MESSAGE
+ // =========================
+
+ const handleSetMessage = () => {
+ const currentValue = `${form[name] || ""}`.trim();
+
+ setErrorText((prev) => ({
+ ...prev,
+ [name]: null,
+ }));
+
+ if (isRequired && currentValue === "") {
+ return setErrorText((prev) => ({
+ ...prev,
+ [name]: textRequired || "Vui lòng nhập trường này",
+ }));
+ }
+
+ if (max && currentValue.length > max) {
+ return setErrorText((prev) => ({
+ ...prev,
+ [name]: `Nhập tối đa ${max} kí tự`,
+ }));
+ }
+
+ if (min && currentValue.length < min) {
+ return setErrorText((prev) => ({
+ ...prev,
+ [name]: `Nhập tối thiểu ${min} kí tự`,
+ }));
+ }
+ };
+
+ // =========================
+ // EFFECTS
+ // =========================
+
+ useEffect(() => {
+ if (countValidate > 0) {
+ handleSetMessage();
+ }
+ }, [countValidate]);
+
+ useEffect(() => {
+ setValidate((prev) => ({
+ ...prev,
+ [name]: handleValidate(),
+ }));
+ }, [form[name]]);
+
+ // =========================
+ // EVENTS
+ // =========================
+
+ const handlerFocused = () => {
+ setIsFocus(true);
+
+ setErrorText((prev) => ({
+ ...prev,
+ [name]: null,
+ }));
+ };
+
+ const handlerBlur = () => {
+ setIsFocus(false);
+
+ if (isBlur) {
+ handleSetMessage();
+
+ setValidate((prev) => ({
+ ...prev,
+ [name]: handleValidate(),
+ }));
+ }
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { value } = e.target;
+
+ setForm((prev: any) => ({
+ ...prev,
+ [name]: value,
+ }));
+ };
+
+ return (
+
+ {/* LABEL */}
+ {label && (
+
+ )}
+
+ {/* TEXTAREA */}
+
+
+ {/* ERROR */}
+ {errorText[name] && (
+
{errorText[name]}
+ )}
+
+ );
+}
+
+export default TextArea;
diff --git a/src/components/utils/FormCustom/components/TextArea/index.ts b/src/components/utils/FormCustom/components/TextArea/index.ts
new file mode 100644
index 0000000..44b0efb
--- /dev/null
+++ b/src/components/utils/FormCustom/components/TextArea/index.ts
@@ -0,0 +1 @@
+export { default } from "./TextArea";
diff --git a/src/components/utils/FormCustom/components/TextArea/interface/index.ts b/src/components/utils/FormCustom/components/TextArea/interface/index.ts
new file mode 100644
index 0000000..03a58e7
--- /dev/null
+++ b/src/components/utils/FormCustom/components/TextArea/interface/index.ts
@@ -0,0 +1,17 @@
+export interface PropsTextArea {
+ name: string;
+ value?: string;
+ placeholder: string;
+
+ label?: string | React.ReactNode;
+
+ isBlur?: boolean;
+ showDone?: boolean;
+ readOnly?: boolean;
+ textRequired?: string;
+
+ max?: number;
+ min?: number;
+
+ isRequired?: boolean;
+}
diff --git a/src/components/utils/FormCustom/contexts/index.ts b/src/components/utils/FormCustom/contexts/index.ts
new file mode 100644
index 0000000..804da4a
--- /dev/null
+++ b/src/components/utils/FormCustom/contexts/index.ts
@@ -0,0 +1,23 @@
+import { createContext, Dispatch, SetStateAction } from "react";
+
+export interface IContextFormCustom {
+ form: T;
+ setForm: Dispatch>;
+ errorText: Record;
+ setErrorText: Dispatch>>;
+ setValidate: Dispatch | null>>;
+ countValidate: number;
+ setCountValidate: Dispatch>;
+ isDone: boolean;
+}
+
+export const ContextFormCustom = createContext>({
+ form: {} as any,
+ setForm: () => {},
+ errorText: {},
+ setErrorText: () => {},
+ setValidate: () => {},
+ countValidate: 0,
+ setCountValidate: () => {},
+ isDone: false,
+});
diff --git a/src/components/utils/FormCustom/index.ts b/src/components/utils/FormCustom/index.ts
new file mode 100644
index 0000000..8f859f9
--- /dev/null
+++ b/src/components/utils/FormCustom/index.ts
@@ -0,0 +1 @@
+export { default } from "./FormCustom";
diff --git a/src/components/utils/FormCustom/interface/index.ts b/src/components/utils/FormCustom/interface/index.ts
new file mode 100644
index 0000000..79d1a72
--- /dev/null
+++ b/src/components/utils/FormCustom/interface/index.ts
@@ -0,0 +1,8 @@
+import { Dispatch, SetStateAction } from "react";
+
+export interface PropsFormCustom> {
+ children: React.ReactNode;
+ form: T;
+ setForm: Dispatch>;
+ onSubmit?: () => void;
+}
diff --git a/src/components/utils/IconRound.tsx b/src/components/utils/IconRound.tsx
new file mode 100644
index 0000000..4c92cea
--- /dev/null
+++ b/src/components/utils/IconRound.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import React, { memo } from "react";
+import Image from "next/image";
+import clsx from "clsx";
+import icons from "@/constant/images/icons";
+
+// 👉 Types
+export const ICON_ROUND_TYPES = [
+ "successPlay",
+ "success",
+ "error",
+ "errorEnd",
+ "errorGoods",
+] as const;
+
+export type IconRoundType = (typeof ICON_ROUND_TYPES)[number];
+
+export interface PropsIconRound {
+ type?: IconRoundType;
+ icon?: React.ReactNode;
+ className?: string;
+ size?: number;
+}
+
+// 👉 Icon map
+const iconMap = {
+ success: icons.tickCircle,
+ successPlay: icons.successPlay,
+ error: icons.errorWarning,
+ errorEnd: icons.errorEnd,
+ errorGoods: icons.errorGoods,
+};
+
+// 🎨 Background mapping
+const outerBgMap: Record = {
+ success: "bg-green-100",
+ successPlay: "bg-green-100",
+ error: "bg-red-100",
+ errorEnd: "bg-red-100",
+ errorGoods: "bg-red-100",
+};
+
+const innerBgMap: Record = {
+ success: "bg-green-200",
+ successPlay: "bg-green-200",
+ error: "bg-red-200",
+ errorEnd: "bg-red-200",
+ errorGoods: "bg-red-200",
+};
+
+function IconRound({
+ type = "success",
+ icon,
+ className,
+ size = 24,
+}: PropsIconRound) {
+ const renderDefaultIcon = () => {
+ const imgSrc = iconMap[type] ?? icons.tickCircle;
+ return (
+
+ );
+ };
+
+ return (
+
+
+ {icon ?? renderDefaultIcon()}
+
+
+ );
+}
+
+export default memo(IconRound);
diff --git a/src/components/utils/PositionContainer/PositionContainer.tsx b/src/components/utils/PositionContainer/PositionContainer.tsx
new file mode 100644
index 0000000..34ce4d5
--- /dev/null
+++ b/src/components/utils/PositionContainer/PositionContainer.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { ReactNode, useEffect, useRef, useState } from "react";
+
+import { createPortal } from "react-dom";
+import clsx from "clsx";
+
+import { PropsPositionContainer } from "./interface";
+
+export default function PositionContainer({
+ children,
+ open,
+ onClose,
+ disableOverlay,
+ idParent,
+ classStyle,
+}: PropsPositionContainer) {
+ const containerRef = useRef(null);
+
+ // ✅ create portal element ONCE
+ const [portalElement] = useState(() => {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ return document.createElement("div");
+ });
+
+ // ================= APPEND PORTAL =================
+ useEffect(() => {
+ if (!portalElement) return;
+
+ const parent = idParent ? document.getElementById(idParent) : document.body;
+
+ if (!parent) return;
+
+ parent.appendChild(portalElement);
+
+ return () => {
+ parent.removeChild(portalElement);
+ };
+ }, [idParent, portalElement]);
+
+ // ================= CLICK OUTSIDE =================
+ useEffect(() => {
+ if (!disableOverlay || !open) return;
+
+ const handleMouseUp = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+
+ const insideClick = target.closest(".click");
+
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(target) &&
+ !insideClick
+ ) {
+ onClose();
+ }
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [disableOverlay, open, onClose]);
+
+ // ================= SSR SAFE =================
+ if (!portalElement) return null;
+
+ return createPortal(
+
+ {/* overlay */}
+ {!disableOverlay && (
+
+ )}
+
+ {/* panel */}
+
+ {children}
+
+
,
+ portalElement,
+ );
+}
diff --git a/src/components/utils/PositionContainer/components/ClickOpen/ClickOpen.tsx b/src/components/utils/PositionContainer/components/ClickOpen/ClickOpen.tsx
new file mode 100644
index 0000000..3528f9e
--- /dev/null
+++ b/src/components/utils/PositionContainer/components/ClickOpen/ClickOpen.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { PropsClickOpen } from "./interface";
+
+export default function ClickOpen({
+ children,
+ onClick,
+ duration = 100,
+}: PropsClickOpen) {
+ const timeoutRef = useRef(null);
+
+ const handleClick = () => {
+ // clear timeout cũ nếu spam click
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+
+ onClick(false);
+
+ timeoutRef.current = setTimeout(() => {
+ onClick(true);
+ }, duration);
+ };
+
+ useEffect(() => {
+ return () => {
+ // cleanup khi unmount
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ return {children}
;
+}
diff --git a/src/components/utils/PositionContainer/components/ClickOpen/index.ts b/src/components/utils/PositionContainer/components/ClickOpen/index.ts
new file mode 100644
index 0000000..19f3c55
--- /dev/null
+++ b/src/components/utils/PositionContainer/components/ClickOpen/index.ts
@@ -0,0 +1 @@
+export { default } from "./ClickOpen";
diff --git a/src/components/utils/PositionContainer/components/ClickOpen/interface/index.ts b/src/components/utils/PositionContainer/components/ClickOpen/interface/index.ts
new file mode 100644
index 0000000..5f77f1b
--- /dev/null
+++ b/src/components/utils/PositionContainer/components/ClickOpen/interface/index.ts
@@ -0,0 +1,5 @@
+export interface PropsClickOpen {
+ children: any;
+ onClick: (bol: boolean) => void;
+ duration?: number;
+}
diff --git a/src/components/utils/PositionContainer/index.ts b/src/components/utils/PositionContainer/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/utils/PositionContainer/interface/index.ts b/src/components/utils/PositionContainer/interface/index.ts
new file mode 100644
index 0000000..d0dec60
--- /dev/null
+++ b/src/components/utils/PositionContainer/interface/index.ts
@@ -0,0 +1,11 @@
+export interface PropsPositionContainer {
+ children: any;
+ open: boolean;
+ onClose: () => void;
+ disableOverlay?: boolean;
+ idParent?: string;
+ classStyle?: {
+ main: string;
+ open: string;
+ };
+}
diff --git a/src/components/utils/UploadAvatar/UploadAvatar.tsx b/src/components/utils/UploadAvatar/UploadAvatar.tsx
new file mode 100644
index 0000000..9b6ea67
--- /dev/null
+++ b/src/components/utils/UploadAvatar/UploadAvatar.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import Image from "next/image";
+import { Upload, Trash } from "lucide-react";
+
+import { toastError, toastWarn } from "@/common/funcs/toast";
+import { PropsUploadAvatar } from "./interface";
+
+const MAXIMUM_FILE = 10;
+
+export default function UploadAvatar({
+ path,
+ name,
+ onSetFile,
+ resetPath,
+}: PropsUploadAvatar) {
+ const [imageUrl, setImageUrl] = useState("");
+ const [fileName, setFileName] = useState("");
+
+ const handleSelectImg = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const { size, type, name } = file;
+
+ if (size / 1024 / 1024 > MAXIMUM_FILE) {
+ toastError({ msg: `Kích thước tối đa ${MAXIMUM_FILE}MB` });
+ return;
+ }
+
+ if (!["image/jpeg", "image/jpg", "image/png"].includes(type)) {
+ toastWarn({
+ msg: "Chỉ hỗ trợ định dạng .jpg, .jpeg, .png",
+ });
+ return;
+ }
+
+ const url = URL.createObjectURL(file);
+
+ setImageUrl((prev) => {
+ if (prev) URL.revokeObjectURL(prev);
+ return url;
+ });
+
+ setFileName(name);
+ onSetFile?.(file);
+ };
+
+ useEffect(() => {
+ return () => {
+ if (imageUrl) URL.revokeObjectURL(imageUrl);
+ };
+ }, [imageUrl]);
+
+ const handleRemoveImg = () => {
+ if (imageUrl) URL.revokeObjectURL(imageUrl);
+
+ setImageUrl("");
+ setFileName("");
+
+ onSetFile?.(null);
+ resetPath?.();
+ };
+
+ const displayImage = imageUrl || path;
+
+ return (
+
+ {/* AVATAR */}
+
+
+ {/* RIGHT CONTENT */}
+
+ {/* CONTROL */}
+
+ {/* UPLOAD INPUT */}
+
+
+ {/* DELETE BUTTON */}
+
+
+
+ {/* DESCRIPTION */}
+
+ Hình ảnh dùng làm avatar, tối thiểu 300x300px
+
+
+ Định dạng hỗ trợ: JPG, JPEG, PNG
+
+
+
+ );
+}
diff --git a/src/components/utils/UploadAvatar/index.ts b/src/components/utils/UploadAvatar/index.ts
new file mode 100644
index 0000000..9c7d933
--- /dev/null
+++ b/src/components/utils/UploadAvatar/index.ts
@@ -0,0 +1 @@
+export { default } from "./UploadAvatar";
diff --git a/src/components/utils/UploadAvatar/interface/index.ts b/src/components/utils/UploadAvatar/interface/index.ts
new file mode 100644
index 0000000..34cacd7
--- /dev/null
+++ b/src/components/utils/UploadAvatar/interface/index.ts
@@ -0,0 +1,8 @@
+import { Dispatch, SetStateAction } from "react";
+
+export interface PropsUploadAvatar {
+ path: any;
+ name: string;
+ onSetFile: Dispatch>;
+ resetPath?: () => void;
+}
diff --git a/src/constant/config/enum.ts b/src/constant/config/enum.ts
new file mode 100644
index 0000000..e71b2b3
--- /dev/null
+++ b/src/constant/config/enum.ts
@@ -0,0 +1,12 @@
+export enum TYPE_DATE {
+ ALL = -1,
+ TODAY = 1,
+ YESTERDAY = 2,
+ THIS_WEEK = 3,
+ LAST_WEEK = 4,
+ THIS_MONTH = 5,
+ LAST_MONTH = 6,
+ THIS_YEAR = 7,
+ LAST_7_DAYS = 8,
+ LUA_CHON = 9,
+}
diff --git a/src/constant/config/index.ts b/src/constant/config/index.ts
new file mode 100644
index 0000000..0d7d9a8
--- /dev/null
+++ b/src/constant/config/index.ts
@@ -0,0 +1,57 @@
+import { CircleGauge, HeartPulse, Pill, Users } from "lucide-react";
+
+export enum PATH {
+ HOME = "/",
+ DASHBOARD = "/dashboard",
+ PATIENT = "/patient",
+ CONSULTATION = "/consultation",
+ PRESCRIPTION = "/prescription",
+
+ LOGIN = "/auth/login",
+ REGISTER = "/auth/register",
+ FORGOT_PASSWORD = "/auth/forgot-password",
+
+ PROFILE = "/profile",
+}
+
+export const Menus: {
+ title?: string;
+ group: {
+ path: string;
+ pathActive: string;
+ // isSpecial?: TYPE_SPECIAL;
+ title: string;
+ icon: React.ElementType;
+ }[];
+}[] = [
+ {
+ group: [
+ {
+ title: "Dashboard",
+ icon: CircleGauge,
+ path: PATH.DASHBOARD || PATH.HOME,
+ pathActive: PATH.DASHBOARD,
+ },
+ {
+ title: "Bệnh nhân",
+ icon: Users,
+ path: PATH.PATIENT,
+ pathActive: PATH.PATIENT,
+ },
+ {
+ title: "Phiên khám",
+ icon: HeartPulse,
+ path: PATH.CONSULTATION,
+ pathActive: PATH.CONSULTATION,
+ },
+ {
+ title: "Đơn thuốc",
+ icon: Pill,
+ path: PATH.PRESCRIPTION,
+ pathActive: PATH.PRESCRIPTION,
+ },
+ ],
+ },
+];
+
+export const KEY_STORE = "management-kham-benh";
diff --git a/src/constant/images/icons.ts b/src/constant/images/icons.ts
new file mode 100644
index 0000000..da6ee4f
--- /dev/null
+++ b/src/constant/images/icons.ts
@@ -0,0 +1,23 @@
+import hamburger from "../../../public/static/images/icon-hamburger.svg";
+import full_screen from "../../../public/static/images/full-screen.svg";
+import avatar from "../../../public/static/images/avatar-default.png";
+import tickCircle from "../../../public/static/images/tick-circle.svg";
+import successPlay from "../../../public/static/images/play-circle.svg";
+import errorWarning from "../../../public/static/images/warning-2.svg";
+import errorEnd from "../../../public/static/images/pause.svg";
+import errorGoods from "../../../public/static/images/box-remove.svg";
+import emptyTable from "../../../public/static/images/empty_table.png";
+import emptyFile from "../../../public/static/images/emptyFile.svg";
+
+export default {
+ hamburger,
+ full_screen,
+ avatar,
+ tickCircle,
+ successPlay,
+ errorWarning,
+ errorEnd,
+ errorGoods,
+ emptyTable,
+ emptyFile,
+};
diff --git a/src/contexts/AppProvider.tsx b/src/contexts/AppProvider.tsx
new file mode 100644
index 0000000..f97caaf
--- /dev/null
+++ b/src/contexts/AppProvider.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { ReactNode, useState } from "react";
+
+import { Provider } from "react-redux";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { ToastContainer } from "react-toastify";
+
+import { store } from "@/redux/store";
+
+import SplashScreen from "@/components/protected/SplashScreen";
+import LoadingTopBar from "@/components/protected/LoadingTopBar";
+
+import "react-toastify/dist/ReactToastify.css";
+
+interface Props {
+ children: ReactNode;
+}
+
+export default function Providers({ children }: Props) {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ },
+ },
+ }),
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/redux/reducer/auth.ts b/src/redux/reducer/auth.ts
new file mode 100644
index 0000000..06f0944
--- /dev/null
+++ b/src/redux/reducer/auth.ts
@@ -0,0 +1,45 @@
+import { PayloadAction, createSlice } from "@reduxjs/toolkit";
+
+export interface IDataLoginStorage {
+ usernameStorage: string;
+ passwordStorage: string;
+}
+
+export interface AuthState {
+ token: string | null;
+ isLogin: boolean;
+ dataLoginStorage: IDataLoginStorage | null;
+}
+
+const initialState: AuthState = {
+ token: null,
+ isLogin: false,
+ dataLoginStorage: null,
+};
+
+export const authSlice = createSlice({
+ name: "auth",
+ initialState,
+ reducers: {
+ setToken: (state, action: PayloadAction) => {
+ state.token = action?.payload;
+ },
+ setStateLogin: (state, action: { payload: boolean }) => {
+ state.isLogin = action?.payload;
+ },
+ logout: (state) => {
+ state.isLogin = false;
+ state.token = null;
+ },
+ setDataLoginStorage: (
+ state,
+ action: PayloadAction,
+ ) => {
+ state.dataLoginStorage = action?.payload;
+ },
+ },
+});
+
+export const { setToken, setStateLogin, logout, setDataLoginStorage } =
+ authSlice.actions;
+export default authSlice.reducer;
diff --git a/src/redux/reducer/site.ts b/src/redux/reducer/site.ts
new file mode 100644
index 0000000..3937565
--- /dev/null
+++ b/src/redux/reducer/site.ts
@@ -0,0 +1,28 @@
+import { PayloadAction, createSlice } from "@reduxjs/toolkit";
+
+export interface SiteState {
+ loading: boolean;
+ isRememberPassword: boolean;
+}
+
+const initialState: SiteState = {
+ loading: true,
+ isRememberPassword: false,
+};
+
+export const siteSlice = createSlice({
+ name: "site",
+ initialState,
+ reducers: {
+ setLoading: (state, action: PayloadAction) => {
+ state.loading = action?.payload;
+ },
+ setRememberPassword: (state, action: PayloadAction) => {
+ state.isRememberPassword = action.payload;
+ },
+ },
+});
+
+export const { setLoading, setRememberPassword } = siteSlice.actions;
+// Action creators are generated for each case reducer function
+export default siteSlice.reducer;
diff --git a/src/redux/reducer/user.ts b/src/redux/reducer/user.ts
new file mode 100644
index 0000000..d1a48d4
--- /dev/null
+++ b/src/redux/reducer/user.ts
@@ -0,0 +1,31 @@
+import { PayloadAction, createSlice } from "@reduxjs/toolkit";
+
+interface IUser {
+ accessToken: string;
+ refreshToken: string;
+ accessExpiresAt: string;
+ refreshExpiresAt: string;
+ avatar: string;
+ fullname: string;
+}
+
+export interface UserState {
+ infoUser: IUser | null;
+}
+
+const initialState: UserState = {
+ infoUser: null,
+};
+
+export const userSlice = createSlice({
+ name: "user",
+ initialState,
+ reducers: {
+ setInfoUser: (state, action: PayloadAction) => {
+ state.infoUser = action?.payload;
+ },
+ },
+});
+
+export const { setInfoUser } = userSlice.actions;
+export default userSlice.reducer;
diff --git a/src/redux/store.ts b/src/redux/store.ts
new file mode 100644
index 0000000..44fd864
--- /dev/null
+++ b/src/redux/store.ts
@@ -0,0 +1,21 @@
+import { combineReducers } from "redux";
+import { configureStore } from "@reduxjs/toolkit";
+
+import siteReducer from "./reducer/site";
+import authReducer from "./reducer/auth";
+import userReducer from "./reducer/user";
+
+const reducers = combineReducers({
+ site: siteReducer,
+ auth: authReducer,
+ user: userReducer,
+});
+
+export const store = configureStore({
+ reducer: reducers,
+ devTools: process.env.NODE_ENV !== "production",
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware(),
+});
+
+export type RootState = ReturnType;
+export type AppDispatch = typeof store.dispatch;
diff --git a/src/services/accountServices.ts b/src/services/accountServices.ts
new file mode 100644
index 0000000..9e1f5c2
--- /dev/null
+++ b/src/services/accountServices.ts
@@ -0,0 +1,50 @@
+import axiosClient from ".";
+
+const accountServices = {
+ sendOTP: (
+ data: {
+ email: string;
+ },
+ tokenAxios?: any,
+ ) => {
+ return axiosClient.post(`/Account/send-otp-email`, data, {
+ cancelToken: tokenAxios,
+ });
+ },
+ enterOTP: (
+ data: {
+ email: string;
+ otp: string;
+ },
+ tokenAxios?: any,
+ ) => {
+ return axiosClient.post(`/Account/enter-otp`, data, {
+ cancelToken: tokenAxios,
+ });
+ },
+ changePassForget: (
+ data: {
+ email: string;
+ otp: string;
+ newPass: string;
+ },
+ tokenAxios?: any,
+ ) => {
+ return axiosClient.post(`/Account/reset-password`, data, {
+ cancelToken: tokenAxios,
+ });
+ },
+ changePassword: (
+ data: {
+ oldPass: string;
+ newPass: string;
+ },
+ tokenAxios?: any,
+ ) => {
+ return axiosClient.post(`/Account/change-password`, data, {
+ cancelToken: tokenAxios,
+ });
+ },
+};
+
+export default accountServices;
diff --git a/src/services/authServices.ts b/src/services/authServices.ts
new file mode 100644
index 0000000..989dc00
--- /dev/null
+++ b/src/services/authServices.ts
@@ -0,0 +1,25 @@
+import axiosClient from ".";
+
+const authServices = {
+ login: (
+ data: {
+ username: string;
+ password: string;
+ ip: string;
+ address: string;
+ type: number;
+ },
+ tokenAxios?: any,
+ ) => {
+ return axiosClient.post(`/Auth/login`, data, {
+ cancelToken: tokenAxios,
+ });
+ },
+ logout: (data: void, tokenAxios?: any) => {
+ return axiosClient.post(`/Auth/logout`, data, {
+ cancelToken: tokenAxios,
+ });
+ },
+};
+
+export default authServices;
diff --git a/src/services/index.ts b/src/services/index.ts
new file mode 100644
index 0000000..d81af29
--- /dev/null
+++ b/src/services/index.ts
@@ -0,0 +1,197 @@
+import axios, {
+ AxiosError,
+ AxiosInstance,
+ AxiosResponse,
+ InternalAxiosRequestConfig,
+} from "axios";
+
+import { delay } from "@/common/funcs/delay";
+import { getKeyCert } from "@/common/funcs/optionConvert";
+import { toastInfo, toastSuccess, toastWarn } from "@/common/funcs/toast";
+
+import { logout } from "@/redux/reducer/auth";
+import { setInfoUser } from "@/redux/reducer/user";
+import { store } from "@/redux/store";
+
+interface ApiError {
+ code: number;
+ message: string;
+}
+
+interface ApiResponse {
+ error: ApiError;
+ data?: T;
+ items?: T[];
+ pagination?: unknown;
+}
+
+interface HttpRequestOptions {
+ http: Promise>;
+
+ setLoading?: React.Dispatch>;
+
+ onError?: () => void;
+
+ showMessageSuccess?: boolean;
+ showMessageFailed?: boolean;
+
+ msgSuccess?: string;
+
+ isDropdown?: boolean;
+ isPagination?: boolean;
+ isData?: boolean;
+}
+
+const axiosClient = axios.create({
+ headers: {
+ "content-type": "application/json",
+ },
+
+ baseURL: "/api-proxy",
+
+ timeout: 15000,
+
+ timeoutErrorMessage: "Timeout error request",
+}) as AxiosInstance & {
+ get(url: string, config?: any): Promise>;
+
+ post(
+ url: string,
+ data?: any,
+ config?: any,
+ ): Promise>;
+
+ put(
+ url: string,
+ data?: any,
+ config?: any,
+ ): Promise>;
+
+ delete(url: string, config?: any): Promise>;
+};
+
+axiosClient.interceptors.request.use(
+ async (
+ config: InternalAxiosRequestConfig,
+ ): Promise => {
+ const token = store.getState().auth.token;
+
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+
+ if (!(config.data instanceof FormData)) {
+ config.data = {
+ ...getKeyCert(),
+ ...(config.data || {}),
+ };
+ }
+
+ return config;
+ },
+);
+
+axiosClient.interceptors.response.use(
+ (response) => {
+ return response.data;
+ },
+
+ (error: AxiosError) => {
+ return Promise.reject(error.response?.data || error);
+ },
+);
+
+export default axiosClient;
+
+export const httpRequest = async ({
+ http,
+
+ setLoading,
+
+ msgSuccess = "Thành công",
+
+ showMessageSuccess = false,
+ showMessageFailed = false,
+
+ onError,
+
+ isDropdown = false,
+ isPagination = false,
+ isData = false,
+}: HttpRequestOptions) => {
+ try {
+ setLoading?.(true);
+
+ await delay(500);
+
+ const res = await http;
+
+ if (res.error.code === 0) {
+ if (showMessageSuccess) {
+ toastSuccess({
+ msg: msgSuccess || res.error.message,
+ });
+ }
+
+ if (isDropdown) {
+ return res.items || [];
+ }
+
+ if (isPagination) {
+ return {
+ ...res.data,
+ items: res.items || [],
+ pagination: res.pagination,
+ };
+ }
+
+ if (isData) {
+ return res;
+ }
+
+ return res.data || true;
+ }
+
+ onError?.();
+
+ throw new Error(res.error.message);
+ } catch (error: unknown) {
+ const err = error as AxiosError | Error;
+
+ const errorData = (err as AxiosError)?.response?.data;
+
+ if (errorData?.error?.code === 401 || errorData?.error?.code === 403) {
+ store.dispatch(logout());
+
+ store.dispatch(setInfoUser(null));
+
+ if (showMessageFailed) {
+ toastWarn({
+ msg: errorData?.error?.message || "Có lỗi đã xảy ra!",
+ });
+ }
+ } else if (err instanceof Error) {
+ if (err.message === "ERR_NETWORK" || err.message === "ECONNABORTED") {
+ if (showMessageFailed) {
+ toastInfo({
+ msg: "Kiểm tra kết nối internet",
+ });
+ }
+ } else {
+ if (showMessageFailed) {
+ toastWarn({
+ msg: err.message || "Có lỗi đã xảy ra!",
+ });
+ }
+ }
+ } else {
+ if (showMessageFailed) {
+ toastWarn({
+ msg: "Có lỗi đã xảy ra!",
+ });
+ }
+ }
+ } finally {
+ setLoading?.(false);
+ }
+};
diff --git a/tsconfig.json b/tsconfig.json
index 3a13f90..cf9c65d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -19,7 +19,7 @@
}
],
"paths": {
- "@/*": ["./*"]
+ "@/*": ["./src/*"]
}
},
"include": [
diff --git a/yarn.lock b/yarn.lock
index 0c93ce0..c91235e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -177,7 +177,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0":
+"@babel/types@npm:^7.26.0, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/types@npm:7.29.0"
dependencies:
@@ -733,6 +733,28 @@ __metadata:
languageName: node
linkType: hard
+"@reduxjs/toolkit@npm:^2.12.0":
+ version: 2.12.0
+ resolution: "@reduxjs/toolkit@npm:2.12.0"
+ dependencies:
+ "@standard-schema/spec": "npm:^1.0.0"
+ "@standard-schema/utils": "npm:^0.3.0"
+ immer: "npm:^11.0.0"
+ redux: "npm:^5.0.1"
+ redux-thunk: "npm:^3.1.0"
+ reselect: "npm:^5.1.0"
+ peerDependencies:
+ react: ^16.9.0 || ^17.0.0 || ^18 || ^19
+ react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-redux:
+ optional: true
+ checksum: 10c0/2b85e31294a7139994fd78b08eb3ce706ce3b03b5081c13403b93ba4883a152d637171e4d9d969766238851f8915b1daa9f36b5a0a458aff0ff7600ee38795c9
+ languageName: node
+ linkType: hard
+
"@rtsao/scc@npm:^1.1.0":
version: 1.1.0
resolution: "@rtsao/scc@npm:1.1.0"
@@ -740,6 +762,20 @@ __metadata:
languageName: node
linkType: hard
+"@standard-schema/spec@npm:^1.0.0":
+ version: 1.1.0
+ resolution: "@standard-schema/spec@npm:1.1.0"
+ checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
+ languageName: node
+ linkType: hard
+
+"@standard-schema/utils@npm:^0.3.0":
+ version: 0.3.0
+ resolution: "@standard-schema/utils@npm:0.3.0"
+ checksum: 10c0/6eb74cd13e52d5fc74054df51e37d947ef53f3ab9e02c085665dcca3c38c60ece8d735cebbdf18fbb13c775fbcb9becb3f53109b0e092a63f0f7389ce0993fd0
+ languageName: node
+ linkType: hard
+
"@swc/helpers@npm:0.5.15":
version: 0.5.15
resolution: "@swc/helpers@npm:0.5.15"
@@ -913,6 +949,24 @@ __metadata:
languageName: node
linkType: hard
+"@tanstack/query-core@npm:5.100.11":
+ version: 5.100.11
+ resolution: "@tanstack/query-core@npm:5.100.11"
+ checksum: 10c0/023dac8d751ba05ab3c85d930136f77c93c62639e6fe2cf3b5191bbcf7dbc34ddd29aade85478979b23d0347b3358388200c101f1bdde8561f175164cd888e5f
+ languageName: node
+ linkType: hard
+
+"@tanstack/react-query@npm:^5.100.11":
+ version: 5.100.11
+ resolution: "@tanstack/react-query@npm:5.100.11"
+ dependencies:
+ "@tanstack/query-core": "npm:5.100.11"
+ peerDependencies:
+ react: ^18 || ^19
+ checksum: 10c0/82859cbd1f973942589b2b4ff110bdd048403c98a51f6c2158eb8b3f4d0999d694ec03566c709ab58bc0a85ecec2a75f1e93c50a76d21b8e7d2897f754ec8189
+ languageName: node
+ linkType: hard
+
"@tybys/wasm-util@npm:^0.10.1":
version: 0.10.2
resolution: "@tybys/wasm-util@npm:0.10.2"
@@ -943,6 +997,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/md5@npm:^2":
+ version: 2.3.6
+ resolution: "@types/md5@npm:2.3.6"
+ checksum: 10c0/34cc6b6d01f08263cbb585f7403e78f4cf535dcac1aeb141ccbe179ab8ea2223544c0e4c543bf5a3ab465008f6ce2e1a1ce6138629276e1a0afc6460ec7713d0
+ languageName: node
+ linkType: hard
+
"@types/node@npm:^20":
version: 20.19.41
resolution: "@types/node@npm:20.19.41"
@@ -952,6 +1013,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/nprogress@npm:^0":
+ version: 0.2.3
+ resolution: "@types/nprogress@npm:0.2.3"
+ checksum: 10c0/cac0fe73aca79bc1472a1556f56303df72026f2fcd8dbe311fe84b1f46e454b9040c1e69223d94e3bf156a5986382170c52ebda19aa70f555f1b6855d8f744a6
+ languageName: node
+ linkType: hard
+
"@types/react-dom@npm:^19":
version: 19.2.3
resolution: "@types/react-dom@npm:19.2.3"
@@ -970,6 +1038,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/use-sync-external-store@npm:^0.0.6":
+ version: 0.0.6
+ resolution: "@types/use-sync-external-store@npm:0.0.6"
+ checksum: 10c0/77c045a98f57488201f678b181cccd042279aff3da34540ad242f893acc52b358bd0a8207a321b8ac09adbcef36e3236944390e2df4fcedb556ce7bb2a88f2a8
+ languageName: node
+ linkType: hard
+
"@typescript-eslint/eslint-plugin@npm:8.59.4":
version: 8.59.4
resolution: "@typescript-eslint/eslint-plugin@npm:8.59.4"
@@ -1281,6 +1356,15 @@ __metadata:
languageName: node
linkType: hard
+"agent-base@npm:6":
+ version: 6.0.2
+ resolution: "agent-base@npm:6.0.2"
+ dependencies:
+ debug: "npm:4"
+ checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261
+ languageName: node
+ linkType: hard
+
"ajv@npm:^6.14.0":
version: 6.15.0
resolution: "ajv@npm:6.15.0"
@@ -1444,6 +1528,13 @@ __metadata:
languageName: node
linkType: hard
+"asynckit@npm:^0.4.0":
+ version: 0.4.0
+ resolution: "asynckit@npm:0.4.0"
+ checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
+ languageName: node
+ linkType: hard
+
"available-typed-arrays@npm:^1.0.7":
version: 1.0.7
resolution: "available-typed-arrays@npm:1.0.7"
@@ -1460,6 +1551,18 @@ __metadata:
languageName: node
linkType: hard
+"axios@npm:^1.16.1":
+ version: 1.16.1
+ resolution: "axios@npm:1.16.1"
+ dependencies:
+ follow-redirects: "npm:^1.16.0"
+ form-data: "npm:^4.0.5"
+ https-proxy-agent: "npm:^5.0.1"
+ proxy-from-env: "npm:^2.1.0"
+ checksum: 10c0/2f77e37e6552bbff8a772d058fb09500198e9188c6b20dc799d82dbe12a8cb506f6eed4e4e62a9ba612a35cbab496faa26d68f9bff14a53af6d15c3e136391a7
+ languageName: node
+ linkType: hard
+
"axobject-query@npm:^4.1.0":
version: 4.1.0
resolution: "axobject-query@npm:4.1.0"
@@ -1467,6 +1570,15 @@ __metadata:
languageName: node
linkType: hard
+"babel-plugin-react-compiler@npm:1.0.0":
+ version: 1.0.0
+ resolution: "babel-plugin-react-compiler@npm:1.0.0"
+ dependencies:
+ "@babel/types": "npm:^7.26.0"
+ checksum: 10c0/9406267ada8d7dbdfe8906b40ecadb816a5f4cee2922bee23f7729293b369624ee135b5a9b0f263851c263c9787522ac5d97016c9a2b82d1668300e42b18aff8
+ languageName: node
+ linkType: hard
+
"balanced-match@npm:^1.0.0":
version: 1.0.2
resolution: "balanced-match@npm:1.0.2"
@@ -1589,6 +1701,13 @@ __metadata:
languageName: node
linkType: hard
+"charenc@npm:0.0.2":
+ version: 0.0.2
+ resolution: "charenc@npm:0.0.2"
+ checksum: 10c0/a45ec39363a16799d0f9365c8dd0c78e711415113c6f14787a22462ef451f5013efae8a28f1c058f81fc01f2a6a16955f7a5fd0cd56247ce94a45349c89877d8
+ languageName: node
+ linkType: hard
+
"client-only@npm:0.0.1":
version: 0.0.1
resolution: "client-only@npm:0.0.1"
@@ -1596,6 +1715,13 @@ __metadata:
languageName: node
linkType: hard
+"clsx@npm:^2.1.1":
+ version: 2.1.1
+ resolution: "clsx@npm:2.1.1"
+ checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
+ languageName: node
+ linkType: hard
+
"color-convert@npm:^2.0.1":
version: 2.0.1
resolution: "color-convert@npm:2.0.1"
@@ -1612,6 +1738,15 @@ __metadata:
languageName: node
linkType: hard
+"combined-stream@npm:^1.0.8":
+ version: 1.0.8
+ resolution: "combined-stream@npm:1.0.8"
+ dependencies:
+ delayed-stream: "npm:~1.0.0"
+ checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
+ languageName: node
+ linkType: hard
+
"concat-map@npm:0.0.1":
version: 0.0.1
resolution: "concat-map@npm:0.0.1"
@@ -1637,6 +1772,13 @@ __metadata:
languageName: node
linkType: hard
+"crypt@npm:0.0.2":
+ version: 0.0.2
+ resolution: "crypt@npm:0.0.2"
+ checksum: 10c0/adbf263441dd801665d5425f044647533f39f4612544071b1471962209d235042fb703c27eea2795c7c53e1dfc242405173003f83cf4f4761a633d11f9653f18
+ languageName: node
+ linkType: hard
+
"csstype@npm:^3.2.2":
version: 3.2.3
resolution: "csstype@npm:3.2.3"
@@ -1684,16 +1826,7 @@ __metadata:
languageName: node
linkType: hard
-"debug@npm:^3.2.7":
- version: 3.2.7
- resolution: "debug@npm:3.2.7"
- dependencies:
- ms: "npm:^2.1.1"
- checksum: 10c0/37d96ae42cbc71c14844d2ae3ba55adf462ec89fd3a999459dec3833944cd999af6007ff29c780f1c61153bcaaf2c842d1e4ce1ec621e4fc4923244942e4a02a
- languageName: node
- linkType: hard
-
-"debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.4.0, debug@npm:^4.4.3":
+"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.4.0, debug@npm:^4.4.3":
version: 4.4.3
resolution: "debug@npm:4.4.3"
dependencies:
@@ -1705,6 +1838,15 @@ __metadata:
languageName: node
linkType: hard
+"debug@npm:^3.2.7":
+ version: 3.2.7
+ resolution: "debug@npm:3.2.7"
+ dependencies:
+ ms: "npm:^2.1.1"
+ checksum: 10c0/37d96ae42cbc71c14844d2ae3ba55adf462ec89fd3a999459dec3833944cd999af6007ff29c780f1c61153bcaaf2c842d1e4ce1ec621e4fc4923244942e4a02a
+ languageName: node
+ linkType: hard
+
"deep-is@npm:^0.1.3":
version: 0.1.4
resolution: "deep-is@npm:0.1.4"
@@ -1734,6 +1876,13 @@ __metadata:
languageName: node
linkType: hard
+"delayed-stream@npm:~1.0.0":
+ version: 1.0.0
+ resolution: "delayed-stream@npm:1.0.0"
+ checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
+ languageName: node
+ linkType: hard
+
"detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
@@ -2330,6 +2479,16 @@ __metadata:
languageName: node
linkType: hard
+"follow-redirects@npm:^1.16.0":
+ version: 1.16.0
+ resolution: "follow-redirects@npm:1.16.0"
+ peerDependenciesMeta:
+ debug:
+ optional: true
+ checksum: 10c0/a1e2900163e6f1b4d1ed5c221b607f41decbab65534c63fe7e287e40a5d552a6496e7d9d7d976fa4ba77b4c51c11e5e9f683f10b43011ea11e442ff128d0e181
+ languageName: node
+ linkType: hard
+
"for-each@npm:^0.3.3, for-each@npm:^0.3.5":
version: 0.3.5
resolution: "for-each@npm:0.3.5"
@@ -2339,6 +2498,19 @@ __metadata:
languageName: node
linkType: hard
+"form-data@npm:^4.0.5":
+ version: 4.0.5
+ resolution: "form-data@npm:4.0.5"
+ dependencies:
+ asynckit: "npm:^0.4.0"
+ combined-stream: "npm:^1.0.8"
+ es-set-tostringtag: "npm:^2.1.0"
+ hasown: "npm:^2.0.2"
+ mime-types: "npm:^2.1.12"
+ checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b
+ languageName: node
+ linkType: hard
+
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
@@ -2561,6 +2733,16 @@ __metadata:
languageName: node
linkType: hard
+"https-proxy-agent@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "https-proxy-agent@npm:5.0.1"
+ dependencies:
+ agent-base: "npm:6"
+ debug: "npm:4"
+ checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1
+ languageName: node
+ linkType: hard
+
"ignore@npm:^5.2.0":
version: 5.3.2
resolution: "ignore@npm:5.3.2"
@@ -2575,6 +2757,13 @@ __metadata:
languageName: node
linkType: hard
+"immer@npm:^11.0.0":
+ version: 11.1.8
+ resolution: "immer@npm:11.1.8"
+ checksum: 10c0/df971d8a8f6a5312c6dca4a8437e20b3185d6c9cd6830ad526c18ffd50f4037ef8db4f332b0adbcb9f8c5ee2fbe117cf0623e8554e24777105b3cc9faf866d34
+ languageName: node
+ linkType: hard
+
"import-fresh@npm:^3.2.1":
version: 3.3.1
resolution: "import-fresh@npm:3.3.1"
@@ -2646,6 +2835,13 @@ __metadata:
languageName: node
linkType: hard
+"is-buffer@npm:~1.1.6":
+ version: 1.1.6
+ resolution: "is-buffer@npm:1.1.6"
+ checksum: 10c0/ae18aa0b6e113d6c490ad1db5e8df9bdb57758382b313f5a22c9c61084875c6396d50bbf49315f5b1926d142d74dfb8d31b40d993a383e0a158b15fea7a82234
+ languageName: node
+ linkType: hard
+
"is-bun-module@npm:^2.0.0":
version: 2.0.0
resolution: "is-bun-module@npm:2.0.0"
@@ -2975,15 +3171,29 @@ __metadata:
version: 0.0.0-use.local
resolution: "kham-benh-mo-pho@workspace:."
dependencies:
+ "@reduxjs/toolkit": "npm:^2.12.0"
"@tailwindcss/postcss": "npm:^4"
+ "@tanstack/react-query": "npm:^5.100.11"
+ "@types/md5": "npm:^2"
"@types/node": "npm:^20"
+ "@types/nprogress": "npm:^0"
"@types/react": "npm:^19"
"@types/react-dom": "npm:^19"
+ axios: "npm:^1.16.1"
+ babel-plugin-react-compiler: "npm:1.0.0"
+ clsx: "npm:^2.1.1"
eslint: "npm:^9"
eslint-config-next: "npm:16.2.6"
+ lottie-react: "npm:^2.4.1"
+ lucide-react: "npm:^1.16.0"
+ md5: "npm:^2.3.0"
+ moment: "npm:^2.30.1"
next: "npm:16.2.6"
+ nprogress: "npm:^0.2.0"
react: "npm:19.2.4"
react-dom: "npm:19.2.4"
+ react-redux: "npm:^9.3.0"
+ react-toastify: "npm:^11.1.0"
tailwindcss: "npm:^4"
typescript: "npm:^5"
languageName: unknown
@@ -3162,6 +3372,25 @@ __metadata:
languageName: node
linkType: hard
+"lottie-react@npm:^2.4.1":
+ version: 2.4.1
+ resolution: "lottie-react@npm:2.4.1"
+ dependencies:
+ lottie-web: "npm:^5.10.2"
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 10c0/7f6a0a2cf2af25deabe7b4d4c9917e47d12016673613c59dcdd8ef52e10e914dd0c1f71d4a275d56aaba03fe9d85b333087f7c8d40a8f967ece4a0444c7a3cdf
+ languageName: node
+ linkType: hard
+
+"lottie-web@npm:^5.10.2":
+ version: 5.13.0
+ resolution: "lottie-web@npm:5.13.0"
+ checksum: 10c0/b463ad462169df3f7e04b7f3716274e269842554f1d72b8303ca7245628f2512982a59ae31698b3d57fbcda886228ae160fcb2aa518c6cecd8a95974db2458ee
+ languageName: node
+ linkType: hard
+
"lru-cache@npm:^5.1.1":
version: 5.1.1
resolution: "lru-cache@npm:5.1.1"
@@ -3171,6 +3400,15 @@ __metadata:
languageName: node
linkType: hard
+"lucide-react@npm:^1.16.0":
+ version: 1.16.0
+ resolution: "lucide-react@npm:1.16.0"
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 10c0/d1c4b7f1abdc8b5c729a5bd74d875f0b0db732b2e8d0cc8c76f9e167818b81a6b246a64986ab2fc1ae74cf9f8d16ce2cefe98fe5a6420b31f4fd6742b3181d5a
+ languageName: node
+ linkType: hard
+
"magic-string@npm:^0.30.21":
version: 0.30.21
resolution: "magic-string@npm:0.30.21"
@@ -3187,6 +3425,17 @@ __metadata:
languageName: node
linkType: hard
+"md5@npm:^2.3.0":
+ version: 2.3.0
+ resolution: "md5@npm:2.3.0"
+ dependencies:
+ charenc: "npm:0.0.2"
+ crypt: "npm:0.0.2"
+ is-buffer: "npm:~1.1.6"
+ checksum: 10c0/14a21d597d92e5b738255fbe7fe379905b8cb97e0a49d44a20b58526a646ec5518c337b817ce0094ca94d3e81a3313879c4c7b510d250c282d53afbbdede9110
+ languageName: node
+ linkType: hard
+
"merge2@npm:^1.3.0":
version: 1.4.1
resolution: "merge2@npm:1.4.1"
@@ -3204,6 +3453,22 @@ __metadata:
languageName: node
linkType: hard
+"mime-db@npm:1.52.0":
+ version: 1.52.0
+ resolution: "mime-db@npm:1.52.0"
+ checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
+ languageName: node
+ linkType: hard
+
+"mime-types@npm:^2.1.12":
+ version: 2.1.35
+ resolution: "mime-types@npm:2.1.35"
+ dependencies:
+ mime-db: "npm:1.52.0"
+ checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
+ languageName: node
+ linkType: hard
+
"minimatch@npm:^10.2.2":
version: 10.2.5
resolution: "minimatch@npm:10.2.5"
@@ -3229,6 +3494,13 @@ __metadata:
languageName: node
linkType: hard
+"moment@npm:^2.30.1":
+ version: 2.30.1
+ resolution: "moment@npm:2.30.1"
+ checksum: 10c0/865e4279418c6de666fca7786607705fd0189d8a7b7624e2e56be99290ac846f90878a6f602e34b4e0455c549b85385b1baf9966845962b313699e7cb847543a
+ languageName: node
+ linkType: hard
+
"ms@npm:^2.1.1, ms@npm:^2.1.3":
version: 2.1.3
resolution: "ms@npm:2.1.3"
@@ -3340,6 +3612,13 @@ __metadata:
languageName: node
linkType: hard
+"nprogress@npm:^0.2.0":
+ version: 0.2.0
+ resolution: "nprogress@npm:0.2.0"
+ checksum: 10c0/eab9a923a1ad1eed71a455ecfbc358442dd9bcd71b9fa3fa1c67eddf5159360b182c218f76fca320c97541a1b45e19ced04e6dcb044a662244c5419f8ae9e821
+ languageName: node
+ linkType: hard
+
"object-assign@npm:^4.1.1":
version: 4.1.1
resolution: "object-assign@npm:4.1.1"
@@ -3563,6 +3842,13 @@ __metadata:
languageName: node
linkType: hard
+"proxy-from-env@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "proxy-from-env@npm:2.1.0"
+ checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58
+ languageName: node
+ linkType: hard
+
"punycode@npm:^2.1.0":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
@@ -3595,6 +3881,37 @@ __metadata:
languageName: node
linkType: hard
+"react-redux@npm:^9.3.0":
+ version: 9.3.0
+ resolution: "react-redux@npm:9.3.0"
+ dependencies:
+ "@types/use-sync-external-store": "npm:^0.0.6"
+ use-sync-external-store: "npm:^1.4.0"
+ peerDependencies:
+ "@types/react": ^18.2.25 || ^19
+ react: ^18.0 || ^19
+ redux: ^5.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ redux:
+ optional: true
+ checksum: 10c0/b9f4efcfbfbc90cac9d1709ab3affb1e18a9dc9bd3cceda43bd2e1d9d2394ee0c29df36ec2202d52b566db774888b594c8c5aa86b64f27ef34fca607c687c9e3
+ languageName: node
+ linkType: hard
+
+"react-toastify@npm:^11.1.0":
+ version: 11.1.0
+ resolution: "react-toastify@npm:11.1.0"
+ dependencies:
+ clsx: "npm:^2.1.1"
+ peerDependencies:
+ react: ^18 || ^19
+ react-dom: ^18 || ^19
+ checksum: 10c0/b621b8272bb9287ec19914b773d91c2f7741eedba253f2986150716d30c82ff069ba9780a7ab4cbb511175b3aeb6e35f003aef3f04742d77df79fb8f4c1c3c98
+ languageName: node
+ linkType: hard
+
"react@npm:19.2.4":
version: 19.2.4
resolution: "react@npm:19.2.4"
@@ -3602,6 +3919,22 @@ __metadata:
languageName: node
linkType: hard
+"redux-thunk@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "redux-thunk@npm:3.1.0"
+ peerDependencies:
+ redux: ^5.0.0
+ checksum: 10c0/21557f6a30e1b2e3e470933247e51749be7f1d5a9620069a3125778675ce4d178d84bdee3e2a0903427a5c429e3aeec6d4df57897faf93eb83455bc1ef7b66fd
+ languageName: node
+ linkType: hard
+
+"redux@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "redux@npm:5.0.1"
+ checksum: 10c0/b10c28357194f38e7d53b760ed5e64faa317cc63de1fb95bc5d9e127fab956392344368c357b8e7a9bedb0c35b111e7efa522210cfdc3b3c75e5074718e9069c
+ languageName: node
+ linkType: hard
+
"reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9":
version: 1.0.10
resolution: "reflect.getprototypeof@npm:1.0.10"
@@ -3632,6 +3965,13 @@ __metadata:
languageName: node
linkType: hard
+"reselect@npm:^5.1.0":
+ version: 5.2.0
+ resolution: "reselect@npm:5.2.0"
+ checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e
+ languageName: node
+ linkType: hard
+
"resolve-from@npm:^4.0.0":
version: 4.0.0
resolution: "resolve-from@npm:4.0.0"
@@ -4364,6 +4704,15 @@ __metadata:
languageName: node
linkType: hard
+"use-sync-external-store@npm:^1.4.0":
+ version: 1.6.0
+ resolution: "use-sync-external-store@npm:1.6.0"
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b
+ languageName: node
+ linkType: hard
+
"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1":
version: 1.1.1
resolution: "which-boxed-primitive@npm:1.1.1"