first commit
1
.yarnrc.yml
Normal file
@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
@ -1,26 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
65
app/page.tsx
@ -1,65 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -13,6 +13,12 @@ const eslintConfig = defineConfig([
|
|||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"no-var": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off", // tắt lỗi any
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
16
package.json
@ -9,15 +9,29 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
|
"@tanstack/react-query": "^5.100.11",
|
||||||
|
"axios": "^1.16.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lottie-react": "^2.4.1",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"md5": "^2.3.0",
|
||||||
|
"moment": "^2.30.1",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"nprogress": "^0.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-redux": "^9.3.0",
|
||||||
|
"react-toastify": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/md5": "^2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nprogress": "^0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
620
public/static/anim/loading_screen.json
Normal file
@ -0,0 +1,620 @@
|
|||||||
|
{
|
||||||
|
"v": "5.5.2",
|
||||||
|
"fr": 60,
|
||||||
|
"ip": 20,
|
||||||
|
"op": 90,
|
||||||
|
"w": 200,
|
||||||
|
"h": 200,
|
||||||
|
"nm": "Comp 1",
|
||||||
|
"ddd": 0,
|
||||||
|
"assets": [],
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 1,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "Shape Layer 1",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": { "a": 0, "k": [99.944, 100.007, 0], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [-2.909, 2.363, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [29.135, -29.73], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 40,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 55,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 70, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 9",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [29.135, 2.337], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 35,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 50,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 65, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 8",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 2,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [29.135, 34.455], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 30,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 45,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 7",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 3,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [-2.858, -29.73], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 35,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 50,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 65, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 6",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 4,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [-2.858, 2.337], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 30,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 45,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 5",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 5,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [-2.858, 34.455], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 25,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 40,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 55, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 4",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 6,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [-34.953, -29.73], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 30,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 45,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 3",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 7,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [-34.953, 2.337], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 25,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 40,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 55, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 2",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 8,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ty": "rc",
|
||||||
|
"d": 1,
|
||||||
|
"s": { "a": 0, "k": [33.3, 33.3], "ix": 2 },
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"nm": "Rectangle Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Rect",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.017408838459, 0.417201023476, 0.690012254902, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [-34.953, 34.455], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 20,
|
||||||
|
"s": [100, 100]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": [0.667, 0.667], "y": [1, 1] },
|
||||||
|
"o": { "x": [0.333, 0.333], "y": [0, 0] },
|
||||||
|
"t": 35,
|
||||||
|
"s": [0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 50, "s": [100, 100] }
|
||||||
|
],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Rectangle 1",
|
||||||
|
"np": 3,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 9,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 141,
|
||||||
|
"st": 0,
|
||||||
|
"bm": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markers": []
|
||||||
|
}
|
||||||
BIN
public/static/images/avatar-default.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/static/images/background_auth.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
8
public/static/images/box-remove.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.16992 7.43994L11.9999 12.5499L20.7699 7.46991" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 21.6099V12.5399" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.6101 9.17V14.83C21.6101 14.88 21.6101 14.92 21.6001 14.97C20.9001 14.36 20.0001 14 19.0001 14C18.0601 14 17.1901 14.33 16.5001 14.88C15.5801 15.61 15.0001 16.74 15.0001 18C15.0001 18.75 15.2101 19.46 15.5801 20.06C15.6701 20.22 15.7801 20.37 15.9001 20.51L14.0701 21.52C12.9301 22.16 11.0701 22.16 9.93011 21.52L4.59012 18.56C3.38012 17.89 2.39014 16.21 2.39014 14.83V9.17C2.39014 7.79 3.38012 6.11002 4.59012 5.44002L9.93011 2.48C11.0701 1.84 12.9301 1.84 14.0701 2.48L19.4101 5.44002C20.6201 6.11002 21.6101 7.79 21.6101 9.17Z" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M23 18C23 18.75 22.79 19.46 22.42 20.06C22.21 20.42 21.94 20.74 21.63 21C20.93 21.63 20.01 22 19 22C17.54 22 16.27 21.22 15.58 20.06C15.21 19.46 15 18.75 15 18C15 16.74 15.58 15.61 16.5 14.88C17.19 14.33 18.06 14 19 14C21.21 14 23 15.79 23 18Z" stroke="#CE0000" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.0702 19.0399L17.9502 16.9299" stroke="#CE0000" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.0502 16.96L17.9302 19.0699" stroke="#CE0000" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
39
public/static/images/emptyFile.svg
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M97.125 96.1667C96.7416 96.7052 96.2356 97.1447 95.6487 97.449C95.0619 97.7533 94.411 97.9136 93.75 97.9167H6.25C5.58896 97.9136 4.93813 97.7533 4.35128 97.449C3.76443 97.1447 3.2584 96.7052 2.875 96.1667C19.3333 59.5625 15.9167 67.1667 20.8333 56.25H79.1667C84.4583 68.0208 80.5417 59.3333 97.125 96.1667Z" fill="#4795EA"/>
|
||||||
|
<path d="M20.8333 29.167V56.2503C15.5417 68.0212 19.4583 59.3337 2.87501 96.167C2.3525 95.4704 2.07424 94.621 2.08335 93.7503V64.5837L20.8333 29.167Z" fill="url(#paint0_linear_6600_59437)"/>
|
||||||
|
<path d="M97.9167 64.5837V93.7503C97.9258 94.621 97.6475 95.4704 97.125 96.167C80.6667 59.5628 84.0834 67.167 79.1667 56.2503V29.167L97.9167 64.5837Z" fill="url(#paint1_linear_6600_59437)"/>
|
||||||
|
<path d="M97.9167 64.5831V93.7498C97.9167 94.8549 97.4777 95.9147 96.6963 96.6961C95.9149 97.4775 94.8551 97.9165 93.75 97.9165H6.25004C5.14497 97.9165 4.08516 97.4775 3.30376 96.6961C2.52236 95.9147 2.08337 94.8549 2.08337 93.7498V64.5831H37.6875C38.19 64.5775 38.6775 64.7536 39.0604 65.079C39.4432 65.4044 39.6955 65.8572 39.7709 66.354C40.0921 68.2389 40.9259 69.999 42.181 71.4415C43.4361 72.884 45.0641 73.9532 46.8864 74.532C48.7088 75.1108 50.6554 75.1768 52.5128 74.7228C54.3702 74.2688 56.0668 73.3123 57.4167 71.9581C58.9314 70.4295 59.9419 68.4738 60.3125 66.354C60.3879 65.8572 60.6402 65.4044 61.0231 65.079C61.4059 64.7536 61.8935 64.5775 62.3959 64.5831H97.9167Z" fill="url(#paint2_linear_6600_59437)"/>
|
||||||
|
<path d="M20.8334 29.167H79.1667V56.2503H20.8334V29.167Z" fill="url(#paint3_linear_6600_59437)"/>
|
||||||
|
<path d="M68.75 20.8336C68.7547 17.264 67.7405 13.7673 65.8264 10.7543C63.9122 7.74132 61.1778 5.33733 57.9445 3.82488C54.7112 2.31242 51.1133 1.75434 47.5738 2.21624C44.0342 2.67814 40.7 4.14082 37.9631 6.43237C35.2262 8.72391 33.2003 11.7491 32.1235 15.1524C31.0467 18.5557 30.9637 22.1956 31.8843 25.6444C32.805 29.0932 34.6909 32.2076 37.3206 34.6215C39.9502 37.0353 43.2143 38.6484 46.7292 39.2711L49.0625 43.9794C49.1531 44.1494 49.2882 44.2915 49.4533 44.3905C49.6185 44.4896 49.8074 44.5419 50 44.5419C50.1926 44.5419 50.3815 44.4896 50.5466 44.3905C50.7118 44.2915 50.8469 44.1494 50.9375 43.9794L53.2708 39.2711C57.6046 38.5033 61.5302 36.2355 64.3602 32.8646C67.1901 29.4938 68.7441 25.2349 68.75 20.8336Z" fill="url(#paint4_linear_6600_59437)"/>
|
||||||
|
<path d="M52.9375 20.834L57.7292 16.0632C58.1215 15.6709 58.3419 15.1388 58.3419 14.584C58.3419 14.0293 58.1215 13.4972 57.7292 13.1049C57.3369 12.7126 56.8048 12.4922 56.25 12.4922C55.6952 12.4922 55.1631 12.7126 54.7708 13.1049L50 17.8965L45.2292 13.1049C45.0349 12.9106 44.8043 12.7565 44.5505 12.6514C44.2967 12.5463 44.0247 12.4922 43.75 12.4922C43.1952 12.4922 42.6631 12.7126 42.2708 13.1049C41.8785 13.4972 41.6581 14.0293 41.6581 14.584C41.6581 15.1388 41.8785 15.6709 42.2708 16.0632L47.0625 20.834L42.2708 25.6049C42.0766 25.7991 41.9225 26.0297 41.8174 26.2835C41.7122 26.5373 41.6581 26.8093 41.6581 27.084C41.6581 27.6388 41.8785 28.1709 42.2708 28.5632C42.6631 28.9555 43.1952 29.1759 43.75 29.1759C44.0247 29.1759 44.2967 29.1218 44.5505 29.0167C44.8043 28.9115 45.0349 28.7575 45.2292 28.5632L50 23.7715L54.7708 28.5632C55.1631 28.9555 55.6952 29.1759 56.25 29.1759C56.8048 29.1759 57.3369 28.9555 57.7292 28.5632C58.1215 28.1709 58.3419 27.6388 58.3419 27.084C58.3419 26.5292 58.1215 25.9972 57.7292 25.6049L52.9375 20.834Z" fill="#FFB369"/>
|
||||||
|
<path d="M22.9167 79.167H10.4167C9.26611 79.167 8.33337 80.0997 8.33337 81.2503V89.5837C8.33337 90.7343 9.26611 91.667 10.4167 91.667H22.9167C24.0673 91.667 25 90.7343 25 89.5837V81.2503C25 80.0997 24.0673 79.167 22.9167 79.167Z" fill="url(#paint5_linear_6600_59437)"/>
|
||||||
|
<path d="M18.75 87.5007H14.5833C14.0308 87.5007 13.5009 87.2812 13.1102 86.8905C12.7195 86.4998 12.5 85.9699 12.5 85.4173C12.5 84.8648 12.7195 84.3349 13.1102 83.9442C13.5009 83.5535 14.0308 83.334 14.5833 83.334H18.75C19.3025 83.334 19.8324 83.5535 20.2231 83.9442C20.6138 84.3349 20.8333 84.8648 20.8333 85.4173C20.8333 85.9699 20.6138 86.4998 20.2231 86.8905C19.8324 87.2812 19.3025 87.5007 18.75 87.5007Z" fill="#FFB369"/>
|
||||||
|
<path d="M35.4167 91.6667H31.25C30.6975 91.6667 30.1676 91.4472 29.7769 91.0565C29.3862 90.6658 29.1667 90.1359 29.1667 89.5833C29.1667 89.0308 29.3862 88.5009 29.7769 88.1102C30.1676 87.7195 30.6975 87.5 31.25 87.5H35.4167C35.9692 87.5 36.4991 87.7195 36.8898 88.1102C37.2805 88.5009 37.5 89.0308 37.5 89.5833C37.5 90.1359 37.2805 90.6658 36.8898 91.0565C36.4991 91.4472 35.9692 91.6667 35.4167 91.6667Z" fill="#4795EA"/>
|
||||||
|
<path d="M39.5834 83.3337H31.25C30.6975 83.3337 30.1676 83.1142 29.7769 82.7235C29.3862 82.3328 29.1667 81.8029 29.1667 81.2503C29.1667 80.6978 29.3862 80.1679 29.7769 79.7772C30.1676 79.3865 30.6975 79.167 31.25 79.167H39.5834C40.1359 79.167 40.6658 79.3865 41.0565 79.7772C41.4472 80.1679 41.6667 80.6978 41.6667 81.2503C41.6667 81.8029 41.4472 82.3328 41.0565 82.7235C40.6658 83.1142 40.1359 83.3337 39.5834 83.3337Z" fill="#4795EA"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_6600_59437" x1="11.4583" y1="96.167" x2="11.4583" y2="29.167" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#54A5FF"/>
|
||||||
|
<stop offset="1" stop-color="#8AD3FE"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_6600_59437" x1="88.5417" y1="96.167" x2="88.5417" y2="0.0003288" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#54A5FF"/>
|
||||||
|
<stop offset="1" stop-color="#8AD3FE"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_6600_59437" x1="50" y1="97.9165" x2="50" y2="64.5831" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#54A5FF"/>
|
||||||
|
<stop offset="1" stop-color="#8AD3FE"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_6600_59437" x1="50" y1="56.2503" x2="50" y2="0.000326126" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#54A5FF"/>
|
||||||
|
<stop offset="1" stop-color="#8AD3FE"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_6600_59437" x1="50" y1="44.5419" x2="50" y2="2.08361" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#D3E6F5"/>
|
||||||
|
<stop offset="1" stop-color="#F0F7FC"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint5_linear_6600_59437" x1="8.33337" y1="85.417" x2="25" y2="85.417" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#D3E6F5"/>
|
||||||
|
<stop offset="1" stop-color="#F0F7FC"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/static/images/empty_table.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
3
public/static/images/full-screen.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 3H22V9H20V5H16V3H20ZM4 3H8V5H4V9H2V3H4ZM20 19V15H22V21H16V19H20ZM4 19H8V21H2V15H4V19Z" fill="#AFBED3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 220 B |
5
public/static/images/icon-hamburger copy.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="19" height="17" viewBox="0 0 19 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.0293 1.88257L7.0293 1.88257" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17.0293 8.94141L1.0293 8.94141" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17.0293 16.0002L11.0293 16.0002" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 479 B |
5
public/static/images/icon-hamburger.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="19" height="17" viewBox="0 0 19 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.0293 1.88257L7.0293 1.88257" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17.0293 8.94141L1.0293 8.94141" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17.0293 16.0002L11.0293 16.0002" stroke="#777E90" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 479 B |
4
public/static/images/pause.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.65 19.11V4.89C10.65 3.54 10.08 3 8.64 3H5.01C3.57 3 3 3.54 3 4.89V19.11C3 20.46 3.57 21 5.01 21H8.64C10.08 21 10.65 20.46 10.65 19.11Z" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.0001 19.11V4.89C21.0001 3.54 20.4301 3 18.9901 3H15.3601C13.9301 3 13.3501 3.54 13.3501 4.89V19.11C13.3501 20.46 13.9201 21 15.3601 21H18.9901C20.4301 21 21.0001 20.46 21.0001 19.11Z" stroke="#CE0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 619 B |
3
public/static/images/play-circle.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 11.9999V8.43989C4 4.01989 7.13 2.2099 10.96 4.4199L14.05 6.1999L17.14 7.9799C20.97 10.1899 20.97 13.8099 17.14 16.0199L14.05 17.7999L10.96 19.5799C7.13 21.7899 4 19.9799 4 15.5599V11.9999Z" stroke="#008545" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 411 B |
5
public/static/images/tick-circle.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 6C2.75 7.67 2 9.75 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2C10.57 2 9.2 2.3 7.97 2.85" stroke="#17B26A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15 10.38L16.12 9.25" stroke="#17B26A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7.87988 12L10.6199 14.75L13.1699 12.21" stroke="#17B26A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 563 B |
5
public/static/images/warning-2.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 7.75V13" stroke="#EE0033" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2.91992 8.58003C2.91992 7.46003 3.51993 6.41999 4.48993 5.84999L10.4299 2.42C11.3999 1.86 12.6 1.86 13.58 2.42L19.52 5.84999C20.49 6.40999 21.09 7.45003 21.09 8.58003V15.42C21.09 16.54 20.49 17.58 19.52 18.15L13.58 21.58C12.61 22.14 11.4099 22.14 10.4299 21.58L4.48993 18.15C3.51993 17.59 2.91992 16.55 2.91992 15.42V12.66" stroke="#EE0033" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 16.2V16.3" stroke="#EE0033" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 732 B |
15
src/app/auth/forgot-password/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import LayoutAuth from "@/components/layouts/LayoutAuth";
|
||||||
|
import MainForgotPassword from "@/components/page/auth/MainForgotPassword/MainForgotPassword";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return (
|
||||||
|
<LayoutAuth>
|
||||||
|
<MainForgotPassword />
|
||||||
|
</LayoutAuth>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
15
src/app/auth/login/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import LayoutAuth from "@/components/layouts/LayoutAuth";
|
||||||
|
import MainLogin from "@/components/page/auth/MainLogin";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return (
|
||||||
|
<LayoutAuth>
|
||||||
|
<MainLogin />
|
||||||
|
</LayoutAuth>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
9
src/app/consultation/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
import BaseLayout from "@/components/layouts/BaseLayout";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <BaseLayout title="Phiên khám">Phiên khám</BaseLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
15
src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout";
|
||||||
|
import MainPageHome from "@/components/page/Home/MainPageHome";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return (
|
||||||
|
<BaseLayout title="Dashboard">
|
||||||
|
<MainPageHome />
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
198
src/app/globals.css
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== JODIT CUSTOM UI ===== */
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-container {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-workplace {
|
||||||
|
background: transparent !important;
|
||||||
|
min-height: 120px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-wysiwyg {
|
||||||
|
padding: 18px 20px !important;
|
||||||
|
color: #111827 !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
line-height: 1.7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-toolbar__box {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 14px 14px 10px 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-toolbar-button {
|
||||||
|
width: 34px !important;
|
||||||
|
height: 34px !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-toolbar-button:hover {
|
||||||
|
background: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-toolbar-button__button {
|
||||||
|
min-width: 34px !important;
|
||||||
|
min-height: 34px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-status-bar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-add-new-line {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-ui-group {
|
||||||
|
gap: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-icon {
|
||||||
|
width: 18px !important;
|
||||||
|
height: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-toolbar-button_active {
|
||||||
|
background: #dbeafe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-toolbar-button_active svg {
|
||||||
|
fill: #2563eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper .jodit-wysiwyg:focus {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
|
import Providers from "@/contexts/AppProvider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -23,11 +25,16 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
// <html
|
||||||
lang="en"
|
// lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
// className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
// >
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
// <body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
// </html>
|
||||||
|
<html lang="vi" suppressHydrationWarning>
|
||||||
|
<body>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
11
src/app/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import BaseLayout from "@/components/layouts/BaseLayout/BaseLayout";
|
||||||
|
import MainPageHome from "@/components/page/Home/MainPageHome/MainPageHome";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<BaseLayout title="Dashboard">
|
||||||
|
<MainPageHome />
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/app/patient/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import BaseLayout from "@/components/layouts/BaseLayout";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BaseLayout title="Bệnh nhân">Bệnh nhân</BaseLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
9
src/app/prescription/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
import BaseLayout from "@/components/layouts/BaseLayout";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <BaseLayout title="Đơn thuốc">Đơn thuốc</BaseLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
14
src/common/funcs/convertCoin.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// Hàm chuyển đổi số coin thành chuỗi định dạng tiền tệ
|
||||||
|
export const convertCoin = (coin: number | null) => {
|
||||||
|
return coin
|
||||||
|
? coin.toLocaleString("de-DE", { maximumFractionDigits: 2 })
|
||||||
|
: "0";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hàm chuyển đổi chuỗi hoặc số thành giá trị số
|
||||||
|
export const price = (value: string | number): number => {
|
||||||
|
const numericValue = Number(
|
||||||
|
`${value}`.replace(/[^0-9,]/g, "").replace(",", "."),
|
||||||
|
);
|
||||||
|
return isNaN(numericValue) ? 0 : numericValue;
|
||||||
|
};
|
||||||
3
src/common/funcs/delay.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export async function delay(duration: number) {
|
||||||
|
return await new Promise((resolve) => setTimeout(resolve, duration));
|
||||||
|
}
|
||||||
18
src/common/funcs/localStorage.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export function getItemStorage(key: string) {
|
||||||
|
const value = localStorage.getItem(key)!;
|
||||||
|
if (value !== "undefined") {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setItemStorage(key: string, value: any) {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteItemStorage(...keys: Array<any>) {
|
||||||
|
keys.forEach((key) => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
168
src/common/funcs/optionConvert.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import md5 from "md5";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
export function removeVietnameseTones(str: string): string {
|
||||||
|
if (!str || typeof str !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/đ/g, "d")
|
||||||
|
.replace(/Đ/g, "D");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertWeight(weight: number | null) {
|
||||||
|
if (!weight) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (weight / 1000).toLocaleString("vi-VN", {
|
||||||
|
minimumFractionDigits: 3,
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function obfuscateEmail(email: string) {
|
||||||
|
// Tách phần trước @ và phần tên miền
|
||||||
|
const [username, domain] = email.split("@");
|
||||||
|
|
||||||
|
// Giữ lại ký tự đầu tiên và cuối cùng của tên người dùng
|
||||||
|
const firstChar = username[0];
|
||||||
|
const lastChar = username[username.length - 1];
|
||||||
|
|
||||||
|
// Tạo phần che giấu giữa
|
||||||
|
const middleHidden = "...";
|
||||||
|
|
||||||
|
// Tạo tên người dùng mới với phần che giấu
|
||||||
|
const newUsername = firstChar + middleHidden + lastChar;
|
||||||
|
|
||||||
|
// Kết hợp với tên miền để tạo email đã che giấu
|
||||||
|
const obfuscatedEmail = newUsername + "@" + domain;
|
||||||
|
|
||||||
|
return obfuscatedEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function fancyTimeFormat(duration: number) {
|
||||||
|
// Hours, minutes and seconds
|
||||||
|
var hrs = ~~(duration / 3600);
|
||||||
|
var mins = ~~((duration % 3600) / 60);
|
||||||
|
var secs = ~~duration % 60;
|
||||||
|
|
||||||
|
// Output like "1:01" or "4:03:59" or "123:03:59"
|
||||||
|
var ret = "";
|
||||||
|
|
||||||
|
if (hrs > 0) {
|
||||||
|
ret += "" + hrs + ":" + (mins < 10 ? "0" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
ret += "" + mins + ":" + (secs < 10 ? "0" : "");
|
||||||
|
ret += "" + secs;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeyCert(): {
|
||||||
|
time: string;
|
||||||
|
keyCert: string;
|
||||||
|
} {
|
||||||
|
const key: string = process.env.NEXT_PUBLIC_KEY_CERT!;
|
||||||
|
const time = moment(new Date()).format("MM/DD/YYYY HH:mm:ss");
|
||||||
|
return {
|
||||||
|
time: time,
|
||||||
|
keyCert: md5(`${key}${time}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextAddress(detailAddress: any, address?: string): string {
|
||||||
|
if (
|
||||||
|
!detailAddress?.province &&
|
||||||
|
!detailAddress?.district &&
|
||||||
|
!detailAddress?.town &&
|
||||||
|
!address
|
||||||
|
) {
|
||||||
|
return "---";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
address,
|
||||||
|
detailAddress?.town?.name,
|
||||||
|
detailAddress?.district?.name,
|
||||||
|
detailAddress?.province?.name,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return parts.length ? parts.join(", ") : "---";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberToWords(number: number) {
|
||||||
|
if (typeof number !== "number" || isNaN(number)) {
|
||||||
|
return "Không hợp lệ";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (number === 0) {
|
||||||
|
return "Không đồng";
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = [
|
||||||
|
"",
|
||||||
|
"một",
|
||||||
|
"hai",
|
||||||
|
"ba",
|
||||||
|
"bốn",
|
||||||
|
"năm",
|
||||||
|
"sáu",
|
||||||
|
"bảy",
|
||||||
|
"tám",
|
||||||
|
"chín",
|
||||||
|
];
|
||||||
|
const levels = ["", "nghìn", "triệu", "tỷ", "nghìn tỷ", "triệu tỷ", "tỷ tỷ"];
|
||||||
|
|
||||||
|
function readThreeDigits(num: number) {
|
||||||
|
let str = "";
|
||||||
|
const hundred = Math.floor(num / 100);
|
||||||
|
const ten = Math.floor((num % 100) / 10);
|
||||||
|
const unit = num % 10;
|
||||||
|
|
||||||
|
if (hundred > 0) {
|
||||||
|
str += units[hundred] + " trăm";
|
||||||
|
if (ten === 0 && unit > 0) str += " linh";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ten > 1) {
|
||||||
|
str += " " + units[ten] + " mươi";
|
||||||
|
if (unit === 1) str += " mốt";
|
||||||
|
else if (unit === 5) str += " lăm";
|
||||||
|
else if (unit > 0) str += " " + units[unit];
|
||||||
|
} else if (ten === 1) {
|
||||||
|
str += " mười";
|
||||||
|
if (unit > 0) str += " " + units[unit];
|
||||||
|
} else if (unit > 0) {
|
||||||
|
str += " " + units[unit];
|
||||||
|
}
|
||||||
|
|
||||||
|
return str.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitIntoChunks(num: number) {
|
||||||
|
const chunks = [];
|
||||||
|
while (num > 0) {
|
||||||
|
chunks.push(num % 1000);
|
||||||
|
num = Math.floor(num / 1000);
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = splitIntoChunks(number);
|
||||||
|
let result = "";
|
||||||
|
|
||||||
|
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||||
|
if (chunks[i] > 0) {
|
||||||
|
result += readThreeDigits(chunks[i]) + " " + levels[i] + " ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.trim().replace(/\s+/g, " ");
|
||||||
|
result = result.charAt(0).toUpperCase() + result.slice(1) + " đồng";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
99
src/common/funcs/selectData.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { TYPE_DATE } from "@/constant/config/enum";
|
||||||
|
|
||||||
|
export function getDateRange(range: number): {
|
||||||
|
from: Date | null;
|
||||||
|
to: Date | null;
|
||||||
|
} {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case TYPE_DATE.ALL:
|
||||||
|
return { from: null, to: null };
|
||||||
|
|
||||||
|
// Tuần này
|
||||||
|
case TYPE_DATE.TODAY:
|
||||||
|
return { from: new Date(), to: new Date() };
|
||||||
|
|
||||||
|
// Hôm qua
|
||||||
|
case TYPE_DATE.YESTERDAY:
|
||||||
|
const firstYesterday = new Date();
|
||||||
|
firstYesterday.setDate(today.getDate() - 1);
|
||||||
|
|
||||||
|
const lastYesterday = new Date();
|
||||||
|
|
||||||
|
lastYesterday.setDate(today.getDate() - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: firstYesterday,
|
||||||
|
to: lastYesterday,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tuần này
|
||||||
|
case TYPE_DATE.THIS_WEEK:
|
||||||
|
const firstDayOfWeek = new Date(today);
|
||||||
|
firstDayOfWeek.setDate(today.getDate() - today.getDay());
|
||||||
|
|
||||||
|
const lastDayOfWeek = new Date(today);
|
||||||
|
lastDayOfWeek.setDate(today.getDate() - today.getDay() + 6);
|
||||||
|
|
||||||
|
return { from: firstDayOfWeek, to: lastDayOfWeek };
|
||||||
|
|
||||||
|
// Tuần trước
|
||||||
|
case TYPE_DATE.LAST_WEEK:
|
||||||
|
const firstDayLastWeek = new Date(today);
|
||||||
|
firstDayLastWeek.setDate(today.getDate() - today.getDay() - 7);
|
||||||
|
|
||||||
|
const lastDayLastWeek = new Date(today);
|
||||||
|
lastDayLastWeek.setDate(today.getDate() - today.getDay() - 1);
|
||||||
|
|
||||||
|
return { from: firstDayLastWeek, to: lastDayLastWeek };
|
||||||
|
|
||||||
|
// Tháng này
|
||||||
|
case TYPE_DATE.THIS_MONTH:
|
||||||
|
const firstDayOfMonth = new Date(
|
||||||
|
today.getFullYear(),
|
||||||
|
today.getMonth(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const lastDayOfMonth = new Date(
|
||||||
|
today.getFullYear(),
|
||||||
|
today.getMonth() + 1,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { from: firstDayOfMonth, to: lastDayOfMonth };
|
||||||
|
|
||||||
|
// Tháng trước
|
||||||
|
case TYPE_DATE.LAST_MONTH:
|
||||||
|
const firstDayLastMonth = new Date(
|
||||||
|
today.getFullYear(),
|
||||||
|
today.getMonth() - 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const lastDayLastMonth = new Date(
|
||||||
|
today.getFullYear(),
|
||||||
|
today.getMonth(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { from: firstDayLastMonth, to: lastDayLastMonth };
|
||||||
|
|
||||||
|
// Năm nay
|
||||||
|
case TYPE_DATE.THIS_YEAR:
|
||||||
|
const firstDayOfYear = new Date(today.getFullYear(), 0, 1);
|
||||||
|
const lastDayOfYear = new Date(today.getFullYear(), 11, 31);
|
||||||
|
|
||||||
|
return { from: firstDayOfYear, to: lastDayOfYear };
|
||||||
|
|
||||||
|
// 7 ngày trước
|
||||||
|
case TYPE_DATE.LAST_7_DAYS:
|
||||||
|
const firstDayLast7Days = new Date(today);
|
||||||
|
firstDayLast7Days.setDate(today.getDate() - 7);
|
||||||
|
|
||||||
|
return { from: firstDayLast7Days, to: today };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { from: null, to: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/common/funcs/toast.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { toast } from "react-toastify";
|
||||||
|
import IconToastifyCustom from "@/components/customs/custom-toast";
|
||||||
|
|
||||||
|
export const toastText = ({ msg }: { msg: string }) =>
|
||||||
|
toast.info(msg, {
|
||||||
|
position: "top-center",
|
||||||
|
autoClose: 2000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
closeButton: false,
|
||||||
|
className: "toastify-custom",
|
||||||
|
icon: IconToastifyCustom({ type: "info" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toastSuccess = ({ msg }: { msg: string }) =>
|
||||||
|
toast.success(msg, {
|
||||||
|
autoClose: 2000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
position: "top-center",
|
||||||
|
closeButton: true,
|
||||||
|
className: "toastify-custom-success",
|
||||||
|
icon: IconToastifyCustom({ type: "success" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toastInfo = ({ msg }: { msg: string }) =>
|
||||||
|
toast.info(msg, {
|
||||||
|
autoClose: 2000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
position: "top-center",
|
||||||
|
closeButton: true,
|
||||||
|
className: "toastify-custom-info",
|
||||||
|
icon: IconToastifyCustom({ type: "info" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toastWarn = ({ msg }: { msg: string }) =>
|
||||||
|
toast.warning(msg, {
|
||||||
|
autoClose: 2000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
position: "top-center",
|
||||||
|
closeButton: true,
|
||||||
|
className: "toastify-custom-warn",
|
||||||
|
icon: IconToastifyCustom({ type: "warn" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toastError = ({ msg }: { msg: string }) =>
|
||||||
|
toast.error(msg, {
|
||||||
|
autoClose: 2000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
position: "top-center",
|
||||||
|
closeButton: true,
|
||||||
|
className: "toastify-custom-error",
|
||||||
|
icon: IconToastifyCustom({ type: "error" }),
|
||||||
|
});
|
||||||
14
src/common/funcs/validate.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export function validateEmail(value: string) {
|
||||||
|
return !!value
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.match(
|
||||||
|
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePhoneNumber(phoneNumber: string) {
|
||||||
|
const vietnamPhoneRegex = /^(03|05|07|08|09)[0-9]{8}$/;
|
||||||
|
|
||||||
|
return vietnamPhoneRegex.test(phoneNumber);
|
||||||
|
}
|
||||||
14
src/common/hooks/useStyleClass.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export const useStyleClass = (props: any, style: any) => {
|
||||||
|
let styleProps: Array<any> = [];
|
||||||
|
|
||||||
|
for (let i in props) {
|
||||||
|
styleProps.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const convert: any = styleProps.map((item) => {
|
||||||
|
if (style[item] !== undefined) {
|
||||||
|
return { [style[item]]: !!props[item] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return convert;
|
||||||
|
};
|
||||||
64
src/components/customs/DataWrapper.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import icons from "@/constant/images/icons";
|
||||||
|
|
||||||
|
interface PropsDataWrapper {
|
||||||
|
title?: string;
|
||||||
|
note?: string;
|
||||||
|
loading: boolean;
|
||||||
|
data: unknown[];
|
||||||
|
children: ReactNode;
|
||||||
|
button?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataWrapper({
|
||||||
|
title = "Dữ liệu trống",
|
||||||
|
note = "Hiện tại dữ liệu đang trống!",
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
button,
|
||||||
|
children,
|
||||||
|
}: PropsDataWrapper) {
|
||||||
|
// ================= LOADING =================
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[55vh] flex flex-col items-center justify-center">
|
||||||
|
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
|
||||||
|
<h3 className="text-2xl text-[#303b48] mt-6">Đang tải...</h3>
|
||||||
|
<p className="text-sm text-[#515c69] mt-1">
|
||||||
|
Vui lòng chờ, đang tải dữ liệu!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= EMPTY =================
|
||||||
|
if (!loading && (!data || data.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[55vh] flex flex-col items-center justify-center text-center">
|
||||||
|
<Image
|
||||||
|
src={icons.emptyTable}
|
||||||
|
alt="empty-data"
|
||||||
|
width={180}
|
||||||
|
height={180}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-2xl text-[#303b48] mt-6">{title}</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-[#515c69] mt-1">{note}</p>
|
||||||
|
|
||||||
|
{button && <div className="mt-5">{button}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= HAS DATA =================
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataWrapper;
|
||||||
64
src/components/customs/StateActive.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
|
interface StateItem {
|
||||||
|
state: number | string;
|
||||||
|
text: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsStateActive {
|
||||||
|
isBox?: boolean;
|
||||||
|
stateActive: number | string;
|
||||||
|
listState: StateItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function StateActive({
|
||||||
|
isBox = true,
|
||||||
|
stateActive,
|
||||||
|
listState,
|
||||||
|
}: PropsStateActive) {
|
||||||
|
// memo trạng thái hiện tại
|
||||||
|
const current = useMemo(() => {
|
||||||
|
return listState.find((item) => item.state === stateActive);
|
||||||
|
}, [stateActive, listState]);
|
||||||
|
|
||||||
|
if (isBox) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
w-fit rounded-full text-sm font-normal
|
||||||
|
"
|
||||||
|
style={{
|
||||||
|
color: current?.textColor ?? "#202939",
|
||||||
|
backgroundColor: current?.backgroundColor,
|
||||||
|
padding: current?.backgroundColor ? "6px 12px" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{current?.text ?? "---"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 w-fit">
|
||||||
|
{/* DOT */}
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: current?.backgroundColor }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TEXT */}
|
||||||
|
<p
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: current?.textColor ?? "#23262f" }}
|
||||||
|
>
|
||||||
|
{current?.text ?? "---"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StateActive;
|
||||||
123
src/components/customs/custom-button.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export interface PropsButton {
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
href?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
target?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
variant?:
|
||||||
|
| "default"
|
||||||
|
| "midnightBlue"
|
||||||
|
| "green"
|
||||||
|
| "red"
|
||||||
|
| "grey"
|
||||||
|
| "white"
|
||||||
|
| "black"
|
||||||
|
| "warning";
|
||||||
|
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
rounded?: "sm" | "md" | "lg" | "full";
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
target,
|
||||||
|
disabled = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
rounded = "md",
|
||||||
|
fullWidth = true,
|
||||||
|
}: PropsButton) {
|
||||||
|
// 🎨 Variant
|
||||||
|
const variantClasses: Record<string, string> = {
|
||||||
|
default: "bg-gray-100 text-gray-700 border border-gray-200 cursor-pointer",
|
||||||
|
midnightBlue:
|
||||||
|
"bg-blue-900 text-white border border-blue-900 cursor-pointer",
|
||||||
|
green: "bg-green-500 text-white border border-green-500 cursor-pointer",
|
||||||
|
red: "bg-red-500 text-white border border-red-500 cursor-pointer",
|
||||||
|
grey: "bg-white text-gray-700 border border-gray-200 cursor-pointer",
|
||||||
|
white:
|
||||||
|
"bg-white text-gray-700 border border-gray-200 shadow-sm cursor-pointer",
|
||||||
|
black: "bg-gray-800 text-white border border-gray-800 cursor-pointer",
|
||||||
|
warning: "bg-orange-400 text-white border border-orange-400 cursor-pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 📏 Size
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "px-3 py-1 text-sm",
|
||||||
|
md: "px-5 py-2 text-sm",
|
||||||
|
lg: "px-6 py-3 text-base",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔵 Rounded
|
||||||
|
const roundedClasses = {
|
||||||
|
sm: "rounded-md",
|
||||||
|
md: "rounded-xl",
|
||||||
|
lg: "rounded-2xl",
|
||||||
|
full: "rounded-full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClass = clsx(
|
||||||
|
"inline-flex items-center justify-center gap-2",
|
||||||
|
"transition-all duration-200 select-none",
|
||||||
|
"active:scale-[0.98] hover:opacity-90",
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
roundedClasses[rounded],
|
||||||
|
fullWidth ? "w-full" : "w-fit",
|
||||||
|
disabled && "opacity-40 pointer-events-none cursor-not-allowed",
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{icon && <span className="flex items-center">{icon}</span>}
|
||||||
|
<span>{children}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onClick?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔗 Link mode
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
className={baseClass}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : undefined}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔘 Button mode
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={baseClass}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/customs/custom-dialog.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import IconRound, { IconRoundType } from "../utils/IconRound";
|
||||||
|
import CustomPopup from "./custom-popup";
|
||||||
|
import CustomButton from "./custom-button";
|
||||||
|
|
||||||
|
/* ================== TYPES ================== */
|
||||||
|
export interface PropsDialog {
|
||||||
|
open: boolean;
|
||||||
|
title: React.ReactNode;
|
||||||
|
note?: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
titleCancel?: string;
|
||||||
|
titleSubmit?: string;
|
||||||
|
type?: IconRoundType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================== COMPONENT ================== */
|
||||||
|
export default function CustomDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
note,
|
||||||
|
titleCancel = "Hủy bỏ",
|
||||||
|
titleSubmit = "Xác nhận",
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
type = "success",
|
||||||
|
}: PropsDialog) {
|
||||||
|
return (
|
||||||
|
<CustomPopup open={open} onClose={onClose}>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className="
|
||||||
|
relative w-[440px] max-w-[90vw]
|
||||||
|
rounded-2xl
|
||||||
|
bg-white shadow-lg
|
||||||
|
px-6 py-7
|
||||||
|
flex flex-col items-center text-center
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* ICON */}
|
||||||
|
<IconRound type={type} />
|
||||||
|
|
||||||
|
{/* TITLE */}
|
||||||
|
<h4 className="mt-4 text-xl font-semibold text-gray-800">{title}</h4>
|
||||||
|
|
||||||
|
{/* NOTE */}
|
||||||
|
{note && <p className="mt-2 text-sm text-gray-500">{note}</p>}
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<div className="w-full flex items-center gap-3 mt-6">
|
||||||
|
<CustomButton onClick={onClose} variant="grey" rounded="full">
|
||||||
|
{titleCancel}
|
||||||
|
</CustomButton>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
onClick={onSubmit}
|
||||||
|
variant="midnightBlue"
|
||||||
|
rounded="full"
|
||||||
|
>
|
||||||
|
{titleSubmit}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomPopup>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/customs/custom-input.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, useMemo, useRef } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
lenght?: number;
|
||||||
|
onSetValue?: (value: any) => void;
|
||||||
|
name?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InputSingle({
|
||||||
|
className,
|
||||||
|
lenght = 6,
|
||||||
|
onSetValue,
|
||||||
|
name = "otp_code",
|
||||||
|
}: Props) {
|
||||||
|
const inputsRef = useRef<HTMLInputElement[]>([]);
|
||||||
|
|
||||||
|
// update value
|
||||||
|
const updateOTPValue = () => {
|
||||||
|
const code = inputsRef.current.map((input) => input?.value || "");
|
||||||
|
|
||||||
|
onSetValue?.((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: code.join(""),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// input change
|
||||||
|
const handleChange = (e: React.FormEvent<HTMLInputElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
|
||||||
|
const index = Number(target.dataset.index);
|
||||||
|
|
||||||
|
let value = target.value;
|
||||||
|
|
||||||
|
if (value.length > 1) {
|
||||||
|
value = value.slice(0, 1);
|
||||||
|
|
||||||
|
target.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOTPValue();
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
inputsRef.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// backspace
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
const index = Number(e.currentTarget.dataset.index);
|
||||||
|
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
if (!e.currentTarget.value) {
|
||||||
|
inputsRef.current[index - 1]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateOTPValue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// set refs
|
||||||
|
const setRef = (el: HTMLInputElement | null, index: number) => {
|
||||||
|
if (el) {
|
||||||
|
inputsRef.current[index] = el;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInputs = useMemo(() => {
|
||||||
|
const list: ReactNode[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lenght; i++) {
|
||||||
|
list.push(
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={(el) => setRef(el, i)}
|
||||||
|
data-index={i}
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={1}
|
||||||
|
onInput={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={clsx(
|
||||||
|
"h-[72px] w-[56px]",
|
||||||
|
"rounded-xl border-2 border-[#dae1ea]",
|
||||||
|
"text-center text-[42px] font-medium",
|
||||||
|
"outline-none transition",
|
||||||
|
"focus:border-[#066bb1]",
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [lenght]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex items-center justify-center gap-3", className)}>
|
||||||
|
{renderInputs}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/customs/custom-loading.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import CustomPortal from "./custom-portal";
|
||||||
|
|
||||||
|
interface PropsLoading {
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCALE = 2.5;
|
||||||
|
|
||||||
|
export default function CustomLoading({ loading }: PropsLoading) {
|
||||||
|
if (!loading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomPortal>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center bg-black/60 z-[10002]">
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
width: 40 * SCALE,
|
||||||
|
height: 40 * SCALE,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
|
const rotate = i * 30;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
transformOrigin: `${20 * SCALE}px ${20 * SCALE}px`,
|
||||||
|
transform: `rotate(${rotate}deg)`,
|
||||||
|
animation: "spinnerFade 1.2s linear infinite",
|
||||||
|
animationDelay: `${-(1.1 - i * 0.1)}s`,
|
||||||
|
}}
|
||||||
|
className="absolute top-0 left-0 w-full h-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
top: 1.5 * SCALE,
|
||||||
|
left: 18.5 * SCALE,
|
||||||
|
width: 3 * SCALE,
|
||||||
|
height: 9 * SCALE,
|
||||||
|
}}
|
||||||
|
className="absolute bg-white rounded-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
src/components/customs/custom-pagination.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ChevronDown, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
export interface PropsPagination {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
onSetPage: (page: number) => void;
|
||||||
|
pageSize: number;
|
||||||
|
onSetPageSize: (pageSize: number) => void;
|
||||||
|
dependencies?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper component to forward ref for the dropdown trigger
|
||||||
|
const TriggerButton = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
onClick: () => void;
|
||||||
|
className: string;
|
||||||
|
pageSize: number;
|
||||||
|
openLimit: boolean;
|
||||||
|
}
|
||||||
|
>(({ onClick, className, pageSize, openLimit }, ref) => (
|
||||||
|
<div ref={ref} onClick={onClick} className={className}>
|
||||||
|
<span className="text-sm font-medium">{pageSize}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={clsx(
|
||||||
|
"text-gray-500 transition-transform",
|
||||||
|
openLimit && "rotate-180 text-[#0011ab]",
|
||||||
|
)}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
TriggerButton.displayName = "TriggerButton";
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onSetPage,
|
||||||
|
onSetPageSize,
|
||||||
|
dependencies = [],
|
||||||
|
}: PropsPagination) {
|
||||||
|
const pageSizes = [50, 100, 200];
|
||||||
|
|
||||||
|
const [openLimit, setOpenLimit] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const maxPage = Math.ceil(total / Number(pageSize));
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const nodes: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= maxPage; i++) {
|
||||||
|
const isCurrent = page === i;
|
||||||
|
const isNear =
|
||||||
|
i === page ||
|
||||||
|
i === page - 1 ||
|
||||||
|
i === page + 1 ||
|
||||||
|
i === 1 ||
|
||||||
|
i === maxPage;
|
||||||
|
|
||||||
|
if (isNear) {
|
||||||
|
nodes.push(
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSetPage(i)}
|
||||||
|
className={clsx(
|
||||||
|
"h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold border rounded cursor-pointer transition",
|
||||||
|
isCurrent
|
||||||
|
? "bg-[#0011ab] text-white border-[#0011ab] pointer-events-none"
|
||||||
|
: "bg-white text-black border-[#efefef] hover:opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i}
|
||||||
|
</li>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((i === page - 2 && page >= 4) || (i === page + 2 && i < maxPage)) {
|
||||||
|
nodes.push(
|
||||||
|
<li
|
||||||
|
key={`dot-${i}`}
|
||||||
|
className="h-[30px] w-[30px] flex items-center justify-center text-[13px] font-semibold"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</li>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}, [maxPage, page, onSetPage]);
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (page > 1) onSetPage(page - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (page < maxPage) onSetPage(page + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dependencies.length > 0) {
|
||||||
|
onSetPage(1);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, dependencies);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
openLimit &&
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setOpenLimit(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [openLimit]);
|
||||||
|
|
||||||
|
if (total <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap-reverse items-center justify-between gap-2">
|
||||||
|
{/* LEFT */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<p>Hiển thị</p>
|
||||||
|
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<TriggerButton
|
||||||
|
onClick={() => setOpenLimit(!openLimit)}
|
||||||
|
className={clsx(
|
||||||
|
"w-[80px] flex items-center justify-between px-3 py-1 border rounded-full cursor-pointer bg-white",
|
||||||
|
"border-gray-300",
|
||||||
|
)}
|
||||||
|
pageSize={pageSize}
|
||||||
|
openLimit={openLimit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{openLimit && (
|
||||||
|
<div className="absolute right-0 z-10 w-[80px] border border-gray-300 bg-white rounded p-1 mt-1 shadow-lg">
|
||||||
|
{pageSizes.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v}
|
||||||
|
onClick={() => {
|
||||||
|
onSetPageSize(v);
|
||||||
|
setOpenLimit(false);
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"px-2 py-1 text-[13px] cursor-pointer rounded",
|
||||||
|
"hover:bg-[#0011ab] hover:text-white",
|
||||||
|
pageSize === v && "bg-[#0011ab] text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
trong tổng <span className="font-semibold">{total}</span> kết quả
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PAGINATION */}
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{page > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
className="h-[30px] w-[30px] flex items-center justify-center border rounded text-[#0011ab] hover:bg-[#0011ab] hover:text-white transition mr-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="flex items-center gap-2">{items}</ul>
|
||||||
|
|
||||||
|
{page < maxPage && (
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="h-[30px] w-[30px] flex items-center justify-center border rounded text-[#0011ab] hover:bg-[#0011ab] hover:text-white transition ml-2"
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/customs/custom-popup.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { ReactNode, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
/* ================== TYPES ================== */
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
isFull?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
showOverlay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================== COMPONENT ================== */
|
||||||
|
export default function CustomPopup({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
showOverlay = true,
|
||||||
|
isFull,
|
||||||
|
children,
|
||||||
|
}: Props) {
|
||||||
|
// lock body scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const original = document.body.style.overflow;
|
||||||
|
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = original || "auto";
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// SSR safe
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
// popup closed
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* OVERLAY */}
|
||||||
|
{showOverlay && (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
className="
|
||||||
|
fixed
|
||||||
|
inset-0
|
||||||
|
z-[10000]
|
||||||
|
bg-[#141416]/50
|
||||||
|
animate-[fadeIn_0.2s_ease-in]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CONTENT */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
`
|
||||||
|
fixed
|
||||||
|
z-[10001]
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
top-1/2
|
||||||
|
left-1/2
|
||||||
|
-translate-x-1/2
|
||||||
|
-translate-y-1/2
|
||||||
|
`,
|
||||||
|
isFull &&
|
||||||
|
`
|
||||||
|
max-[768px]:top-0
|
||||||
|
max-[768px]:left-0
|
||||||
|
max-[768px]:right-0
|
||||||
|
max-[768px]:bottom-0
|
||||||
|
max-[768px]:translate-x-0
|
||||||
|
max-[768px]:translate-y-0
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/customs/custom-portal.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, useLayoutEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
containerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomPortal({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
containerId,
|
||||||
|
}: Props) {
|
||||||
|
// ✅ tạo element 1 lần duy nhất
|
||||||
|
const element = useMemo(() => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
|
||||||
|
if (className) {
|
||||||
|
el.className = className;
|
||||||
|
}
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}, [className]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const parent = containerId
|
||||||
|
? document.getElementById(containerId)
|
||||||
|
: document.body;
|
||||||
|
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
parent.appendChild(element);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
parent.removeChild(element);
|
||||||
|
};
|
||||||
|
}, [element, containerId]);
|
||||||
|
|
||||||
|
// ✅ không dùng ref.current
|
||||||
|
// ✅ không dùng setState
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
return createPortal(children, element);
|
||||||
|
}
|
||||||
56
src/components/customs/custom-search.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Search as SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface PropsSearch {
|
||||||
|
placeholder?: string;
|
||||||
|
keyword?: string;
|
||||||
|
setKeyword: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Search({
|
||||||
|
keyword = "",
|
||||||
|
setKeyword,
|
||||||
|
placeholder = "Nhập từ khóa tìm kiếm",
|
||||||
|
}: PropsSearch) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState(keyword);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// setSearchTerm(keyword);
|
||||||
|
// }, [keyword]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setKeyword(searchTerm.trim());
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
className={clsx(
|
||||||
|
"flex h-12 cursor-text items-center gap-2 rounded-full border bg-white px-4 transition-colors",
|
||||||
|
"border-[#e1e5ed] hover:border-[#3772ff] focus-within:border-[#3772ff]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SearchIcon size={20} className="text-[#908F99]" />
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="
|
||||||
|
flex-1 border-none bg-transparent p-2
|
||||||
|
text-[16px] font-medium outline-none
|
||||||
|
placeholder:text-[#777e90]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
src/components/customs/custom-table.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
ChevronUp,
|
||||||
|
ArrowDownUp,
|
||||||
|
ArrowDownWideNarrow,
|
||||||
|
ArrowDownNarrowWide,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/* ================= TYPES ================= */
|
||||||
|
interface ColumnType<T> {
|
||||||
|
title: string | React.ReactNode;
|
||||||
|
render: (row: T, index: number, path?: number[]) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
checkBox?: boolean;
|
||||||
|
fixedLeft?: boolean;
|
||||||
|
fixedRight?: boolean;
|
||||||
|
maxWidth?: number | string;
|
||||||
|
sortTable?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsTable<T> {
|
||||||
|
data: T[];
|
||||||
|
column: ColumnType<T>[];
|
||||||
|
fixedHeader?: boolean;
|
||||||
|
activeHeader?: boolean;
|
||||||
|
handleCheckedAll?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
isCheckedAll?: boolean;
|
||||||
|
handleCheckedRow?: (e: React.ChangeEvent<HTMLInputElement>, row: T) => void;
|
||||||
|
handleIsCheckedRow?: (row: T) => boolean;
|
||||||
|
rowKey: (row: T) => React.Key;
|
||||||
|
getChildren?: (row: T) => T[] | undefined;
|
||||||
|
useIndexPathAsKey?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= COMPONENT ================= */
|
||||||
|
export default function Table<T>({
|
||||||
|
data,
|
||||||
|
column,
|
||||||
|
fixedHeader,
|
||||||
|
activeHeader,
|
||||||
|
handleCheckedAll,
|
||||||
|
isCheckedAll,
|
||||||
|
handleCheckedRow,
|
||||||
|
handleIsCheckedRow,
|
||||||
|
rowKey,
|
||||||
|
getChildren,
|
||||||
|
useIndexPathAsKey,
|
||||||
|
}: PropsTable<T>) {
|
||||||
|
const tableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const thRefs = useRef<(HTMLTableCellElement | null)[]>([]);
|
||||||
|
|
||||||
|
const [expandedRows, setExpandedRows] = useState<React.Key[]>([]);
|
||||||
|
const [sortConfig, setSortConfig] = useState<{
|
||||||
|
key: string;
|
||||||
|
direction: "asc" | "desc" | null;
|
||||||
|
}>({ key: "", direction: null });
|
||||||
|
|
||||||
|
/* ================= SORT ================= */
|
||||||
|
const handleSort = (key: string) => {
|
||||||
|
setSortConfig((prev) => {
|
||||||
|
if (prev.key === key) {
|
||||||
|
const next =
|
||||||
|
prev.direction === "asc"
|
||||||
|
? "desc"
|
||||||
|
: prev.direction === "desc"
|
||||||
|
? null
|
||||||
|
: "asc";
|
||||||
|
return { key, direction: next };
|
||||||
|
}
|
||||||
|
return { key, direction: "asc" };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedData = useMemo(() => {
|
||||||
|
if (!sortConfig.key || !sortConfig.direction) return data;
|
||||||
|
|
||||||
|
const copy = [...data];
|
||||||
|
|
||||||
|
copy.sort((a: any, b: any) => {
|
||||||
|
const aVal = a?.[sortConfig.key];
|
||||||
|
const bVal = b?.[sortConfig.key];
|
||||||
|
|
||||||
|
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||||
|
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||||
|
return sortConfig.direction === "asc"
|
||||||
|
? aVal.localeCompare(bVal)
|
||||||
|
: bVal.localeCompare(aVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
}, [data, sortConfig]);
|
||||||
|
|
||||||
|
/* ================= EXPAND ================= */
|
||||||
|
const toggleExpand = (key: React.Key) => {
|
||||||
|
setExpandedRows((prev) =>
|
||||||
|
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ================= ROWS ================= */
|
||||||
|
const renderRows = (rows: T[], path: number[] = []): React.ReactNode =>
|
||||||
|
rows.map((row, i) => {
|
||||||
|
const currentPath = [...path, i];
|
||||||
|
const keyPath = useIndexPathAsKey ? currentPath.join(".") : rowKey(row);
|
||||||
|
|
||||||
|
const children = getChildren?.(row);
|
||||||
|
const expanded = expandedRows.includes(keyPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={String(keyPath)}>
|
||||||
|
<tr className="bg-white border-b">
|
||||||
|
{getChildren && (
|
||||||
|
<td className="w-[60px] text-center">
|
||||||
|
{!!children?.length && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(keyPath)}
|
||||||
|
className={clsx(
|
||||||
|
"transition text-gray-400 hover:text-blue-500",
|
||||||
|
expanded && "rotate-180 text-blue-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronUp size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{column.map((col, idx) => (
|
||||||
|
<td
|
||||||
|
key={idx}
|
||||||
|
className={clsx(
|
||||||
|
"px-4 py-3 text-sm font-medium whitespace-nowrap",
|
||||||
|
col.fixedLeft && "sticky left-0 bg-white z-10",
|
||||||
|
col.fixedRight && "sticky right-0 bg-white z-10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth:
|
||||||
|
typeof col.maxWidth === "number"
|
||||||
|
? `${col.maxWidth}px`
|
||||||
|
: col.maxWidth,
|
||||||
|
}}
|
||||||
|
className={clsx("flex items-center gap-2")}
|
||||||
|
>
|
||||||
|
{col.checkBox && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 accent-blue-500 cursor-pointer"
|
||||||
|
onChange={(e) => handleCheckedRow?.(e, row)}
|
||||||
|
checked={handleIsCheckedRow?.(row) ?? false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{col.render(row, i, currentPath)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{expanded && children && renderRows(children, currentPath)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ================= UI ================= */
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={tableRef}
|
||||||
|
className={clsx(
|
||||||
|
"overflow-auto pb-2",
|
||||||
|
fixedHeader && "[&>table>thead]:sticky [&>table>thead]:top-0",
|
||||||
|
activeHeader && "[&>table>thead]:bg-[#F4F7FA]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead className="bg-white border-b">
|
||||||
|
<tr>
|
||||||
|
{getChildren && <th className="w-[60px]" />}
|
||||||
|
|
||||||
|
{column.map((col, i) => {
|
||||||
|
const isActive = col.sortTable === sortConfig.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
thRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
onClick={() => col.sortTable && handleSort(col.sortTable)}
|
||||||
|
className={clsx(
|
||||||
|
"px-4 py-3 text-left text-[15px] font-semibold whitespace-nowrap",
|
||||||
|
col.sortTable && "cursor-pointer",
|
||||||
|
isActive && "text-blue-600",
|
||||||
|
col.fixedLeft && "sticky left-0 bg-white z-10",
|
||||||
|
col.fixedRight && "sticky right-0 bg-white z-10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{col.checkBox && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onChange={handleCheckedAll}
|
||||||
|
checked={isCheckedAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{col.title}
|
||||||
|
|
||||||
|
{col.sortTable &&
|
||||||
|
(isActive ? (
|
||||||
|
sortConfig.direction === "asc" ? (
|
||||||
|
<ArrowDownNarrowWide size={14} />
|
||||||
|
) : sortConfig.direction === "desc" ? (
|
||||||
|
<ArrowDownWideNarrow size={14} />
|
||||||
|
) : (
|
||||||
|
<ArrowDownUp size={14} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowDownUp size={14} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>{renderRows(sortedData)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/customs/custom-toast.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CircleCheck, CircleAlert, BadgeCheck } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export interface PropsIconToastifyCustom {
|
||||||
|
type: "success" | "info" | "warn" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_CONFIG = {
|
||||||
|
success: {
|
||||||
|
bg: "bg-[#06d7a0]",
|
||||||
|
icon: CircleCheck,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: "bg-[#4bc9f0]",
|
||||||
|
icon: CircleCheck,
|
||||||
|
},
|
||||||
|
warn: {
|
||||||
|
bg: "bg-[#ffd167]",
|
||||||
|
icon: CircleAlert,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: "bg-[#ee464c]",
|
||||||
|
icon: BadgeCheck,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function IconToastifyCustom({ type }: PropsIconToastifyCustom) {
|
||||||
|
const { bg, icon: Icon } = ICON_CONFIG[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-[50px] h-[50px] flex items-center justify-center rounded-full",
|
||||||
|
bg,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/components/customs/slider-props.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface CustomSliderProps {
|
||||||
|
label?: string;
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
|
||||||
|
showValue?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
sliderClassName?: string;
|
||||||
|
thumbClassName?: string;
|
||||||
|
trackClassName?: string;
|
||||||
|
|
||||||
|
color?: string;
|
||||||
|
trackColor?: string;
|
||||||
|
|
||||||
|
renderValue?: (value: number) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomSlider = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 1,
|
||||||
|
step = 0.01,
|
||||||
|
|
||||||
|
onChange,
|
||||||
|
|
||||||
|
showValue = true,
|
||||||
|
disabled = false,
|
||||||
|
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
sliderClassName,
|
||||||
|
|
||||||
|
color = "#2563eb",
|
||||||
|
trackColor = "#e5e7eb",
|
||||||
|
|
||||||
|
renderValue,
|
||||||
|
}: CustomSliderProps) => {
|
||||||
|
const percentage = ((value - min) / (max - min)) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("w-full space-y-3", className)}>
|
||||||
|
{/* Header */}
|
||||||
|
{(label || showValue) && (
|
||||||
|
<div
|
||||||
|
className={clsx("flex items-center justify-between", labelClassName)}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showValue && (
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{renderValue ? renderValue(value) : value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Slider */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(Number(e.target.value))}
|
||||||
|
className={clsx(
|
||||||
|
`
|
||||||
|
w-full
|
||||||
|
appearance-none
|
||||||
|
bg-transparent
|
||||||
|
cursor-pointer
|
||||||
|
|
||||||
|
disabled:opacity-50
|
||||||
|
disabled:cursor-not-allowed
|
||||||
|
|
||||||
|
[&::-webkit-slider-runnable-track]:h-2
|
||||||
|
[&::-webkit-slider-runnable-track]:rounded-full
|
||||||
|
|
||||||
|
[&::-webkit-slider-thumb]:appearance-none
|
||||||
|
[&::-webkit-slider-thumb]:h-6
|
||||||
|
[&::-webkit-slider-thumb]:w-6
|
||||||
|
[&::-webkit-slider-thumb]:rounded-full
|
||||||
|
[&::-webkit-slider-thumb]:bg-white
|
||||||
|
[&::-webkit-slider-thumb]:border-4
|
||||||
|
[&::-webkit-slider-thumb]:shadow-md
|
||||||
|
[&::-webkit-slider-thumb]:-mt-2
|
||||||
|
|
||||||
|
[&::-moz-range-track]:h-2
|
||||||
|
[&::-moz-range-track]:rounded-full
|
||||||
|
|
||||||
|
[&::-moz-range-thumb]:h-6
|
||||||
|
[&::-moz-range-thumb]:w-6
|
||||||
|
[&::-moz-range-thumb]:rounded-full
|
||||||
|
[&::-moz-range-thumb]:bg-white
|
||||||
|
[&::-moz-range-thumb]:border-4
|
||||||
|
`,
|
||||||
|
sliderClassName,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${color} ${percentage}%, ${trackColor} ${percentage}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomSlider;
|
||||||
71
src/components/layouts/BaseLayout/BaseLayout.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { PropsBaseLayout, TContextBaseLayout } from "./interface";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Header from "./componets/Header";
|
||||||
|
import Navbar from "./componets/Navbar";
|
||||||
|
import RequireAuth from "@/components/protected/RequiredAuth";
|
||||||
|
|
||||||
|
export const ContextBaseLayout = React.createContext<TContextBaseLayout>({});
|
||||||
|
|
||||||
|
const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
|
||||||
|
const [showFull, setShowFull] = React.useState(false);
|
||||||
|
const [openMenuMobile, setOpenMenuMobile] = React.useState(false);
|
||||||
|
return (
|
||||||
|
// <RequireAuth>
|
||||||
|
<ContextBaseLayout
|
||||||
|
value={{ showFull, setShowFull, openMenuMobile, setOpenMenuMobile }}
|
||||||
|
>
|
||||||
|
<div className="min-h-screen bg-[#f8f8f8]">
|
||||||
|
{/* OVERLAY cho Mobile */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"fixed inset-0 bg-black/50 backdrop-blur-sm z-[20] transition-opacity duration-300 xl:hidden",
|
||||||
|
openMenuMobile ? "opacity-100 visible" : "opacity-0 invisible",
|
||||||
|
)}
|
||||||
|
onClick={() => setOpenMenuMobile(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
|
||||||
|
<nav
|
||||||
|
className={clsx(
|
||||||
|
"fixed top-0 left-0 h-full w-[240px] z-[21] bg-white transition-all duration-300 border-r border-[#f4f7fa]",
|
||||||
|
// Desktop logic
|
||||||
|
showFull ? "xl:translate-x-0" : "xl:-translate-x-full",
|
||||||
|
// Mobile logic
|
||||||
|
openMenuMobile ? "translate-x-0" : "-translate-x-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Navbar />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
|
<header
|
||||||
|
className={clsx(
|
||||||
|
"fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]",
|
||||||
|
showFull
|
||||||
|
? "xl:left-[240px] xl:w-[calc(100%-240px)]"
|
||||||
|
: "left-0 w-full",
|
||||||
|
"left-0 w-full", // Mặc định full width trên mobile
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Header title={title} breadcrumb={breadcrumb} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* MAIN CONTENT */}
|
||||||
|
<main
|
||||||
|
className={clsx(
|
||||||
|
"pt-[92px] pb-6 px-6 transition-all duration-300",
|
||||||
|
showFull ? "xl:pl-[264px]" : "xl:pl-6",
|
||||||
|
"pl-6", // Mặc định padding trên mobile
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ContextBaseLayout>
|
||||||
|
// </RequireAuth>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BaseLayout;
|
||||||
101
src/components/layouts/BaseLayout/componets/Header/Header.tsx
Normal file
@ -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 (
|
||||||
|
<div className="h-full w-full px-6 flex items-center justify-between bg-white relatives">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Nút bấm Mobile: Mở menu mobile */}
|
||||||
|
<div
|
||||||
|
className="xl:hidden cursor-pointer"
|
||||||
|
onClick={() => context?.setOpenMenuMobile?.(true)}
|
||||||
|
>
|
||||||
|
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
|
||||||
|
</div>
|
||||||
|
{/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */}
|
||||||
|
<div
|
||||||
|
className="hidden xl:block cursor-pointer"
|
||||||
|
onClick={() => context?.setShowFull?.(!context?.showFull)}
|
||||||
|
>
|
||||||
|
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
|
||||||
|
</div>
|
||||||
|
{breadcrumb ? (
|
||||||
|
breadcrumb
|
||||||
|
) : (
|
||||||
|
<h4 className="text-[18px] font-bold text-[#141416]">{title}</h4>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-7">
|
||||||
|
<div className="cursor-pointer" onClick={toggleFullScreen}>
|
||||||
|
<Image src={icons.full_screen} alt="" width={24} height={24} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative flex items-center gap-2 cursor-pointer"
|
||||||
|
onClick={() => setOpenProfile(!openProfile)}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full border-2 border-blue-500 overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
infoUser?.avatar
|
||||||
|
? `${process.env.NEXT_PUBLIC_IMAGE}/${infoUser.avatar}`
|
||||||
|
: icons.avatar
|
||||||
|
}
|
||||||
|
alt="avatar"
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="hidden md:block text-sm font-semibold text-[#171832]">
|
||||||
|
{infoUser?.fullname || "User admin"}
|
||||||
|
</p>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={clsx(
|
||||||
|
"transition-transform",
|
||||||
|
openProfile && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{openProfile && (
|
||||||
|
<div className="absolute right-0 top-[120%] z-50">
|
||||||
|
<MenuProfile onClose={() => setOpenProfile(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Header";
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export interface PropsHeader {
|
||||||
|
title: string;
|
||||||
|
breadcrumb?: React.ReactNode;
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div className="w-[260px] bg-white rounded-xl shadow-md p-2">
|
||||||
|
{/* PROFILE */}
|
||||||
|
<Link
|
||||||
|
href={PATH.PROFILE}
|
||||||
|
onClick={onClose}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
|
||||||
|
checkActive(PATH.PROFILE) && "bg-gray-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldUser size={20} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Thông tin cá nhân</p>
|
||||||
|
<p className="text-xs text-gray-500">Chi tiết tài khoản</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="h-px bg-gray-200 my-1" />
|
||||||
|
|
||||||
|
{/* CHANGE PASSWORD */}
|
||||||
|
<Link
|
||||||
|
href={`${PATH.PROFILE}?_action=change-password`}
|
||||||
|
onClick={onClose}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
|
||||||
|
pathname === PATH.PROFILE &&
|
||||||
|
searchParams.get("_action") === "change-password" &&
|
||||||
|
"bg-gray-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldCog size={20} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Đổi mật khẩu</p>
|
||||||
|
<p className="text-xs text-gray-500">Thay đổi mật khẩu</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="h-px bg-gray-200 my-1" />
|
||||||
|
|
||||||
|
{/* LOGOUT */}
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setOpenLogout(true);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition"
|
||||||
|
>
|
||||||
|
<LogOut size={20} className="text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-500">Đăng xuất</p>
|
||||||
|
<p className="text-xs text-gray-500">Đăng xuất khỏi hệ thống</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DIALOG */}
|
||||||
|
<CustomDialog
|
||||||
|
open={openLogout}
|
||||||
|
onClose={() => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MenuProfile;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./MenuProfile";
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export interface PropsMenuProfile {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div className="h-full w-full flex flex-col items-center p-3 bg-white">
|
||||||
|
<Link href={PATH.HOME} className="flex items-center justify-center py-2">
|
||||||
|
<Image alt="Logo" src={icons.avatar} width={40} height={40} />
|
||||||
|
<h4 className="ml-2 text-sm font-semibold text-blue-900 select-none">
|
||||||
|
Quản lý
|
||||||
|
</h4>
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1 w-full overflow-auto pt-3">
|
||||||
|
{Menus.map((menu, i) => (
|
||||||
|
<div key={i} className="mb-4">
|
||||||
|
{/* TITLE */}
|
||||||
|
<h5 className="text-xs font-semibold text-gray-400 uppercase mb-2">
|
||||||
|
{menu.title}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{menu.group.map((tab, y) => {
|
||||||
|
const isActive = checkActive(tab.pathActive);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={y}
|
||||||
|
href={tab.path}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-lg transition",
|
||||||
|
isActive
|
||||||
|
? "bg-indigo-100 text-blue-700"
|
||||||
|
: "text-gray-800 hover:bg-indigo-100 hover:text-blue-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon
|
||||||
|
size={22}
|
||||||
|
className={clsx(
|
||||||
|
isActive ? "text-blue-700" : "text-gray-800",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-base font-semibold">{tab.title}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Navbar";
|
||||||
1
src/components/layouts/BaseLayout/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./BaseLayout";
|
||||||
12
src/components/layouts/BaseLayout/interface/index.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
38
src/components/layouts/ChartWrapper/ChartWrapper.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-full min-h-[500px]",
|
||||||
|
isEmpty && "flex items-center justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<Image
|
||||||
|
alt="Ảnh dữ liệu trống"
|
||||||
|
src={icons.emptyTable}
|
||||||
|
width={180}
|
||||||
|
height={180}
|
||||||
|
/>
|
||||||
|
{/* nếu muốn hiện message thì bật dòng dưới */}
|
||||||
|
{/* <p className="mt-3 text-sm text-gray-500">{message}</p> */}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartWrapper;
|
||||||
0
src/components/layouts/ChartWrapper/index.ts
Normal file
5
src/components/layouts/ChartWrapper/interface/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface PropsChartWrapper {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
69
src/components/layouts/GridColumn/GridColumn.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"grid w-full",
|
||||||
|
|
||||||
|
/* ===== GAP ===== */
|
||||||
|
sm ? "gap-2" : "gap-4",
|
||||||
|
|
||||||
|
/* ===== DEFAULT COL ===== */
|
||||||
|
{
|
||||||
|
"grid-cols-1": col === 1,
|
||||||
|
"grid-cols-2": col === 2,
|
||||||
|
"grid-cols-3": col === 3,
|
||||||
|
"grid-cols-4": col === 4,
|
||||||
|
"grid-cols-5": col === 5,
|
||||||
|
"grid-cols-6": col === 6,
|
||||||
|
"grid-cols-8": col === 8,
|
||||||
|
"grid-cols-12": col === 12,
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ===== RESPONSIVE ===== */
|
||||||
|
"xl:grid-cols-3", // giống default SCSS fallback
|
||||||
|
"lg:grid-cols-2",
|
||||||
|
"md:grid-cols-2",
|
||||||
|
"sm:grid-cols-1",
|
||||||
|
|
||||||
|
/* ===== CUSTOM RULES ===== */
|
||||||
|
tabletCol3 && "lg:grid-cols-3",
|
||||||
|
|
||||||
|
mobile2 && "sm:grid-cols-2",
|
||||||
|
|
||||||
|
/* ===== COL 4 SPECIAL ===== */
|
||||||
|
col === 4 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
|
||||||
|
|
||||||
|
/* ===== COL 5 ===== */
|
||||||
|
col === 5 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
|
||||||
|
|
||||||
|
/* ===== SCROLL ===== */
|
||||||
|
scroll20 &&
|
||||||
|
"grid-flow-col auto-cols-[20%] overflow-x-auto snap-x snap-mandatory gap-2",
|
||||||
|
|
||||||
|
scrollMobile85 &&
|
||||||
|
"md:grid-cols-none md:grid-flow-col md:auto-cols-[85%] md:overflow-x-auto md:snap-x md:snap-mandatory",
|
||||||
|
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GridColumn;
|
||||||
1
src/components/layouts/GridColumn/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./GridColumn";
|
||||||
14
src/components/layouts/GridColumn/interface/index.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
40
src/components/layouts/LayoutAuth/LayoutAuth.tsx
Normal file
@ -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 (
|
||||||
|
<RequiredLogout>
|
||||||
|
<div className="h-screen grid grid-cols-2 max-[1200px]:grid-cols-1">
|
||||||
|
{/* BACKGROUND */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
bg-no-repeat
|
||||||
|
bg-cover
|
||||||
|
bg-center
|
||||||
|
max-[1200px]:hidden bg-[url('/static/images/background_auth.jpg')]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MAIN */}
|
||||||
|
<main
|
||||||
|
className="
|
||||||
|
flex items-center bg-white
|
||||||
|
px-[200px]
|
||||||
|
max-[1600px]:px-[100px]
|
||||||
|
max-[768px]:px-[40px]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</RequiredLogout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutAuth;
|
||||||
1
src/components/layouts/LayoutAuth/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./LayoutAuth";
|
||||||
57
src/components/layouts/LayoutPages/LayoutPages.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto whitespace-nowrap pb-1 mb-5 scrollbar-thin">
|
||||||
|
{listPages.map((item, i) => {
|
||||||
|
const isActive = checkActive(item.url);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
href={item.url}
|
||||||
|
onClick={(e) => {
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
<div className="flex items-center gap-2 justify-center">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: isActive ? "#fff" : item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p>{item.title}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* MAIN */}
|
||||||
|
<div>{children}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutPages;
|
||||||
1
src/components/layouts/LayoutPages/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./LayoutPages";
|
||||||
8
src/components/layouts/LayoutPages/interface/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface PropsLayoutPages {
|
||||||
|
children: React.ReactNode;
|
||||||
|
listPages: {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
color: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
8
src/components/page/Home/MainPageHome/MainPageHome.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const MainPageHome = () => {
|
||||||
|
return <div>MainPageHome</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainPageHome;
|
||||||
1
src/components/page/Home/MainPageHome/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./MainPageHome";
|
||||||
@ -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>(
|
||||||
|
TYPE_FORGOT_PASWORD.EMAIL,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<IFormForgotPassword>({
|
||||||
|
email: "",
|
||||||
|
otp: "",
|
||||||
|
password: "",
|
||||||
|
rePassword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* TITLE */}
|
||||||
|
<h3
|
||||||
|
className="
|
||||||
|
text-[34px]
|
||||||
|
font-bold
|
||||||
|
text-[#1A1B2D]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Quên mật khẩu
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* DESCRIPTION */}
|
||||||
|
<p
|
||||||
|
className="
|
||||||
|
mt-1
|
||||||
|
text-[14px]
|
||||||
|
font-medium
|
||||||
|
text-[#6F767E]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
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!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* FORM */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<ContextForgotPassword.Provider
|
||||||
|
value={{
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
type,
|
||||||
|
setType,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{type === TYPE_FORGOT_PASWORD.EMAIL && <FormEmail />}
|
||||||
|
|
||||||
|
{type === TYPE_FORGOT_PASWORD.PASSWORD && <FormPassword />}
|
||||||
|
</ContextForgotPassword.Provider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<FormCustom form={form} setForm={setForm} onSubmit={handleSendOTP}>
|
||||||
|
<CustomLoading loading={sendOTPMutation.isPending} />
|
||||||
|
|
||||||
|
{/* INPUT EMAIL */}
|
||||||
|
<InputForm
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
Email
|
||||||
|
<span className="text-red-500"> *</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placeholder="Nhập email"
|
||||||
|
type="text"
|
||||||
|
name="email"
|
||||||
|
isEmail
|
||||||
|
onClean
|
||||||
|
isRequired
|
||||||
|
isBlur
|
||||||
|
icon={<Mail size={22} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* BUTTON GROUP */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* BUTTON SUBMIT */}
|
||||||
|
<CustomButton
|
||||||
|
// type="submit"
|
||||||
|
disabled={sendOTPMutation.isPending}
|
||||||
|
variant="midnightBlue"
|
||||||
|
rounded="full"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Lấy lại mật khẩu
|
||||||
|
</CustomButton>
|
||||||
|
|
||||||
|
{/* LINE */}
|
||||||
|
<div className="my-5 h-[1px] w-full bg-[#E5E5E5]" />
|
||||||
|
|
||||||
|
{/* LOGIN BUTTON */}
|
||||||
|
<CustomButton
|
||||||
|
// type="button"
|
||||||
|
variant="grey"
|
||||||
|
rounded="full"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => router.push(PATH.LOGIN)}
|
||||||
|
>
|
||||||
|
Đăng nhập ngay
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* POPUP OTP */}
|
||||||
|
<CustomPopup open={open === "otp"} onClose={handleClosePopup}>
|
||||||
|
<FormOTP />
|
||||||
|
</CustomPopup>
|
||||||
|
</FormCustom>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormEmail;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./FormEmail";
|
||||||
@ -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<number>(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 (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
relative
|
||||||
|
w-[476px]
|
||||||
|
rounded-2xl
|
||||||
|
bg-white
|
||||||
|
p-10
|
||||||
|
shadow-[0px_8px_40px_rgba(63,83,115,0.16),0px_3px_24px_rgba(63,83,115,0.2),0px_3px_8px_rgba(63,83,115,0.08),0px_1px_0px_rgba(63,83,115,0.08)]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* LOADING */}
|
||||||
|
<CustomLoading
|
||||||
|
loading={sendOTPMutation.isPending || submitOTPMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TITLE */}
|
||||||
|
<h3
|
||||||
|
className="
|
||||||
|
text-center
|
||||||
|
text-[28px]
|
||||||
|
font-bold
|
||||||
|
text-[#1A1B2D]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Xác thực mã OTP
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* DESCRIPTION */}
|
||||||
|
<p
|
||||||
|
className="
|
||||||
|
mt-1
|
||||||
|
text-center
|
||||||
|
text-[14px]
|
||||||
|
font-medium
|
||||||
|
text-[#6F767E]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Một mã xác thực đã được gửi cho bạn qua địa chỉ email:
|
||||||
|
<span className="font-semibold"> {obfuscateEmail(form.email!)}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* FORM */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* LABEL */}
|
||||||
|
<p
|
||||||
|
className="
|
||||||
|
text-center
|
||||||
|
text-[16px]
|
||||||
|
font-medium
|
||||||
|
text-[#23262F]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Nhập mã OTP
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* INPUT OTP */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<InputSingle onSetValue={setForm} name="otp" lenght={6} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COUNTDOWN */}
|
||||||
|
<p
|
||||||
|
className="
|
||||||
|
mt-5
|
||||||
|
text-center
|
||||||
|
text-[15px]
|
||||||
|
font-medium
|
||||||
|
text-[#23262F]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Bạn chưa nhận được mã.
|
||||||
|
{countDown > 0 ? (
|
||||||
|
<span
|
||||||
|
className="
|
||||||
|
ml-1
|
||||||
|
cursor-default
|
||||||
|
text-[15px]
|
||||||
|
font-medium
|
||||||
|
text-[#0011AB]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Gửi lại OTP ({fancyTimeFormat(countDown)})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onClick={handleSendCode}
|
||||||
|
className="
|
||||||
|
ml-1
|
||||||
|
cursor-pointer
|
||||||
|
text-[15px]
|
||||||
|
font-medium
|
||||||
|
text-[#0011AB]
|
||||||
|
transition-all
|
||||||
|
hover:underline
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Gửi lại OTP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BUTTON */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<CustomButton
|
||||||
|
variant="midnightBlue"
|
||||||
|
rounded="full"
|
||||||
|
className="w-full"
|
||||||
|
disabled={form.otp.length! < 6}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Xác thực Email
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CLOSE */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeForm}
|
||||||
|
className="
|
||||||
|
absolute
|
||||||
|
right-5
|
||||||
|
top-5
|
||||||
|
cursor-pointer
|
||||||
|
select-none
|
||||||
|
transition-all
|
||||||
|
active:scale-90
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormOTP;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./FormOTP";
|
||||||
@ -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<IContextForgotPassword>(
|
||||||
|
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 (
|
||||||
|
<FormCustom form={form} setForm={setForm} onSubmit={handleSubmit}>
|
||||||
|
<CustomLoading loading={loginMutation.isPending} />
|
||||||
|
|
||||||
|
{/* PASSWORD */}
|
||||||
|
<InputForm
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
Mật khẩu mới
|
||||||
|
<span className="text-red-500"> *</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placeholder="Nhập mật khẩu mới"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
onClean
|
||||||
|
isRequired
|
||||||
|
isBlur
|
||||||
|
showDone
|
||||||
|
icon={<ShieldCheck size={22} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* RE PASSWORD */}
|
||||||
|
<div className="mt-5">
|
||||||
|
<InputForm
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
Xác nhận mật khẩu mới
|
||||||
|
<span className="text-red-500"> *</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placeholder="Xác nhận mật khẩu mới"
|
||||||
|
type="password"
|
||||||
|
name="rePassword"
|
||||||
|
valueConfirm={form.password}
|
||||||
|
onClean
|
||||||
|
isRequired
|
||||||
|
isBlur
|
||||||
|
showDone
|
||||||
|
icon={<ShieldCheck size={22} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BUTTON */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<ContextFormCustom.Consumer>
|
||||||
|
{({ isDone }) => (
|
||||||
|
<CustomButton
|
||||||
|
variant="midnightBlue"
|
||||||
|
rounded="full"
|
||||||
|
disabled={!isDone}
|
||||||
|
className="py-[10px] font-bold"
|
||||||
|
>
|
||||||
|
Lấy lại mật khẩu
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</ContextFormCustom.Consumer>
|
||||||
|
</div>
|
||||||
|
</FormCustom>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/page/auth/MainForgotPassword/context/index.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { createContext, Dispatch, SetStateAction } from "react";
|
||||||
|
import { IFormForgotPassword, TYPE_FORGOT_PASWORD } from "../interface";
|
||||||
|
|
||||||
|
export interface IContextForgotPassword {
|
||||||
|
form: IFormForgotPassword;
|
||||||
|
setForm: Dispatch<SetStateAction<IFormForgotPassword>>;
|
||||||
|
type: number;
|
||||||
|
setType: Dispatch<SetStateAction<number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextForgotPassword = createContext<IContextForgotPassword>({
|
||||||
|
form: { email: "", otp: "", password: "", rePassword: "" },
|
||||||
|
setForm: () => null,
|
||||||
|
type: TYPE_FORGOT_PASWORD.EMAIL,
|
||||||
|
setType: () => null,
|
||||||
|
});
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
export interface IFormForgotPassword {
|
||||||
|
email: string;
|
||||||
|
otp: string;
|
||||||
|
password: string;
|
||||||
|
rePassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TYPE_FORGOT_PASWORD {
|
||||||
|
EMAIL = 1,
|
||||||
|
PASSWORD,
|
||||||
|
}
|
||||||
269
src/components/page/auth/MainLogin/MainLogin.tsx
Normal file
@ -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 (
|
||||||
|
<div className="w-full">
|
||||||
|
<CustomLoading loading={loginMutation.isPending} />
|
||||||
|
|
||||||
|
<FormCustom form={form} setForm={setForm} onSubmit={handleLogin}>
|
||||||
|
{/* TITLE */}
|
||||||
|
<h3
|
||||||
|
className="
|
||||||
|
text-[34px]
|
||||||
|
font-semibold
|
||||||
|
text-[#1A1B2D]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Đăng nhập
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="
|
||||||
|
mt-1
|
||||||
|
text-[14px]
|
||||||
|
font-medium
|
||||||
|
text-[#6F767E]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Chào mừng bạn đến với hệ thống quản lý
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* FORM */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* USERNAME */}
|
||||||
|
<InputForm
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
Tài khoản
|
||||||
|
<span className="text-red-500"> *</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placeholder="Tài khoản"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
onClean
|
||||||
|
isRequired
|
||||||
|
isBlur
|
||||||
|
showDone
|
||||||
|
icon={<User size={22} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* PASSWORD */}
|
||||||
|
<div className="mt-5">
|
||||||
|
<InputForm
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
Mật khẩu
|
||||||
|
<span className="text-red-500"> *</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placeholder="Mật khẩu"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
onClean
|
||||||
|
isRequired
|
||||||
|
isBlur
|
||||||
|
showDone
|
||||||
|
icon={<ShieldPlus size={22} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* REMEMBER PASSWORD */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
mt-[14px]
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
gap-2
|
||||||
|
cursor-pointer
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="rememberPassword"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isRememberPassword}
|
||||||
|
onChange={() =>
|
||||||
|
store.dispatch(setRememberPassword(!isRememberPassword))
|
||||||
|
}
|
||||||
|
className="
|
||||||
|
h-5
|
||||||
|
w-5
|
||||||
|
cursor-pointer
|
||||||
|
accent-[#0011AB]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor="rememberPassword"
|
||||||
|
className="
|
||||||
|
cursor-pointer
|
||||||
|
select-none
|
||||||
|
text-[14px]
|
||||||
|
font-medium
|
||||||
|
text-[#171717]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Nhớ mật khẩu
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BUTTONS */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* LOGIN */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loginMutation.isPending}
|
||||||
|
className="
|
||||||
|
flex
|
||||||
|
h-[52px]
|
||||||
|
w-full
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
rounded-full
|
||||||
|
bg-[#0011AB]
|
||||||
|
px-6
|
||||||
|
text-white
|
||||||
|
font-bold
|
||||||
|
transition-all
|
||||||
|
hover:opacity-90
|
||||||
|
disabled:cursor-not-allowed
|
||||||
|
disabled:opacity-50
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* LINE */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
my-5
|
||||||
|
h-[1px]
|
||||||
|
w-full
|
||||||
|
bg-[#E5E5E5]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FORGOT PASSWORD */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(PATH.FORGOT_PASSWORD)}
|
||||||
|
className="
|
||||||
|
flex
|
||||||
|
h-[52px]
|
||||||
|
w-full
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
gap-2
|
||||||
|
rounded-full
|
||||||
|
border
|
||||||
|
border-gray-300
|
||||||
|
bg-white
|
||||||
|
px-6
|
||||||
|
font-bold
|
||||||
|
text-[#171717]
|
||||||
|
transition-all
|
||||||
|
hover:bg-gray-50
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<LockKeyhole size={22} />
|
||||||
|
Quên mật khẩu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormCustom>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/page/auth/MainLogin/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./MainLogin";
|
||||||
35
src/components/protected/LoadingTopBar/LoadingTopBar.tsx
Normal file
@ -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;
|
||||||
1
src/components/protected/LoadingTopBar/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./LoadingTopBar";
|
||||||
30
src/components/protected/RequiredAuth/index.tsx
Normal file
@ -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 <div className="loading-page" />;
|
||||||
|
}
|
||||||
34
src/components/protected/RequiredLogout/index.tsx
Normal file
@ -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 <div className="loading-page"></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RequiredLogout;
|
||||||
111
src/components/protected/SplashScreen/SplashScreen.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"fixed inset-0 flex items-center justify-center z-[1000000] bg-white transition-opacity duration-300",
|
||||||
|
{
|
||||||
|
"opacity-0 invisible": !loading && isClosing,
|
||||||
|
"opacity-100 visible": loading,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-[40vw] max-w-[320px] max-h-[320px]">
|
||||||
|
<Lottie animationData={loadingAnim} loop autoplay />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SplashScreen;
|
||||||
1
src/components/protected/SplashScreen/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./SplashScreen";
|
||||||
70
src/components/utils/FormCustom/FormCustom.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { PropsFormCustom } from "./interface";
|
||||||
|
import { ContextFormCustom } from "./contexts";
|
||||||
|
|
||||||
|
function FormCustom<T extends Record<string, any>>({
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
onSubmit,
|
||||||
|
children,
|
||||||
|
}: PropsFormCustom<T>) {
|
||||||
|
// 👉 tạo object error ban đầu
|
||||||
|
const initialError = useMemo(() => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(form).map((key) => [key, null]),
|
||||||
|
) as Record<keyof T, string | null>;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const [countValidate, setCountValidate] = useState(0);
|
||||||
|
const [errorText, setErrorText] =
|
||||||
|
useState<Record<keyof T, string | null>>(initialError);
|
||||||
|
|
||||||
|
const [validate, setValidate] = useState<Record<keyof T, boolean> | 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<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isDone) {
|
||||||
|
onSubmit?.();
|
||||||
|
} else {
|
||||||
|
setCountValidate((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDone, onSubmit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextFormCustom.Provider
|
||||||
|
value={{
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
errorText,
|
||||||
|
setErrorText,
|
||||||
|
setValidate,
|
||||||
|
countValidate,
|
||||||
|
setCountValidate,
|
||||||
|
isDone,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="w-full">
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
</ContextFormCustom.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormCustom;
|
||||||
@ -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<IContextFormCustom<any>>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* LABEL */}
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
className="
|
||||||
|
mb-2
|
||||||
|
block
|
||||||
|
text-[16px]
|
||||||
|
font-medium
|
||||||
|
text-[#171717]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* INPUT WRAPPER */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
`
|
||||||
|
flex
|
||||||
|
h-12
|
||||||
|
items-center
|
||||||
|
gap-2
|
||||||
|
rounded-full
|
||||||
|
border
|
||||||
|
bg-white
|
||||||
|
px-4
|
||||||
|
transition-all
|
||||||
|
`,
|
||||||
|
"border-[#CDD5DF]",
|
||||||
|
"hover:border-[#0011AB]",
|
||||||
|
|
||||||
|
isFocus && "border-[#0011AB]",
|
||||||
|
|
||||||
|
errorText?.[name] && "border-red-500",
|
||||||
|
|
||||||
|
showDone && isDone && !errorText?.[name] && "border-green-600",
|
||||||
|
|
||||||
|
readOnly &&
|
||||||
|
`
|
||||||
|
cursor-not-allowed
|
||||||
|
border-gray-200
|
||||||
|
bg-gray-100
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* ICON */}
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"transition-all",
|
||||||
|
|
||||||
|
isFocus && "text-[#0011AB]",
|
||||||
|
|
||||||
|
errorText?.[name] && "text-red-500",
|
||||||
|
|
||||||
|
showDone && isDone && !errorText?.[name] && "text-green-600",
|
||||||
|
|
||||||
|
!isFocus && !errorText?.[name] && "text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* INPUT */}
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={isPassword && showPass ? "text" : type}
|
||||||
|
value={String(currentValue)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={readOnly}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="
|
||||||
|
flex-1
|
||||||
|
bg-transparent
|
||||||
|
text-[16px]
|
||||||
|
font-medium
|
||||||
|
outline-none
|
||||||
|
placeholder:text-gray-400
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* UNIT */}
|
||||||
|
{unit ? (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
border-l
|
||||||
|
border-gray-300
|
||||||
|
pl-3
|
||||||
|
text-[16px]
|
||||||
|
font-medium
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{unit}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* CLEAN */}
|
||||||
|
{onClean && !!currentValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClean}
|
||||||
|
className="
|
||||||
|
cursor-pointer
|
||||||
|
transition-all
|
||||||
|
hover:opacity-70
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CircleX size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DONE */}
|
||||||
|
{!isPassword &&
|
||||||
|
showDone &&
|
||||||
|
isDone &&
|
||||||
|
!errorText?.[name] &&
|
||||||
|
!!currentValue && <Check size={18} className="text-green-600" />}
|
||||||
|
|
||||||
|
{/* PASSWORD */}
|
||||||
|
{isPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPass(!showPass)}
|
||||||
|
className="
|
||||||
|
cursor-pointer
|
||||||
|
transition-all
|
||||||
|
hover:opacity-70
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{showPass ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ERROR */}
|
||||||
|
{errorText?.[name] && (
|
||||||
|
<p
|
||||||
|
className="
|
||||||
|
mt-1
|
||||||
|
text-xs
|
||||||
|
font-medium
|
||||||
|
text-red-500
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{errorText[name]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* NOTE */}
|
||||||
|
{note && (
|
||||||
|
<p
|
||||||
|
className="
|
||||||
|
mt-1
|
||||||
|
text-xs
|
||||||
|
font-medium
|
||||||
|
text-gray-500
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InputForm;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./InputForm";
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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<OptionType>({
|
||||||
|
label,
|
||||||
|
placeholder = "Chọn...",
|
||||||
|
isSearch = true,
|
||||||
|
readOnly,
|
||||||
|
value,
|
||||||
|
options = [], // ✅ FIX NULL
|
||||||
|
onClean,
|
||||||
|
onSelect,
|
||||||
|
getOptionLabel,
|
||||||
|
getOptionValue,
|
||||||
|
}: PropsSelectForm<OptionType>) {
|
||||||
|
const refWrapper = useRef<HTMLDivElement>(null);
|
||||||
|
const refInput = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className="relative w-full" ref={refWrapper}>
|
||||||
|
{/* LABEL */}
|
||||||
|
{label && (
|
||||||
|
<label className="block mb-2 text-[16px] font-medium">{label}</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SELECT BOX */}
|
||||||
|
<div
|
||||||
|
onClick={() => !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 */}
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 text-[16px] font-medium",
|
||||||
|
!selectedOption && "text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedOption ? getOptionLabel(selectedOption) : placeholder}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CLEAR */}
|
||||||
|
{onClean && !!value && !readOnly && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClean();
|
||||||
|
}}
|
||||||
|
className="mr-2 hover:opacity-70"
|
||||||
|
>
|
||||||
|
<CircleX size={18} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ARROW */}
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={clsx(
|
||||||
|
"transition",
|
||||||
|
open && "rotate-180 text-blue-600",
|
||||||
|
readOnly && "text-gray-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DROPDOWN */}
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 w-full bg-white border border-blue-600 rounded-2xl p-3 shadow-lg z-50 absolute">
|
||||||
|
{/* SEARCH */}
|
||||||
|
{isSearch && (
|
||||||
|
<div className="flex items-center gap-2 px-3 h-[42px] border border-gray-300 rounded-full mb-3">
|
||||||
|
<Search size={18} className="text-blue-600" />
|
||||||
|
<input
|
||||||
|
ref={refInput}
|
||||||
|
value={keyword}
|
||||||
|
placeholder="Tìm kiếm..."
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
className="flex-1 bg-transparent outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LIST */}
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
<div className="max-h-[300px] overflow-y-auto space-y-1">
|
||||||
|
{filteredOptions.map((opt) => {
|
||||||
|
const active = getOptionValue(opt) === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(getOptionValue(opt))}
|
||||||
|
onClick={() => 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)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[200px] flex flex-col items-center justify-center">
|
||||||
|
<Image src={icons.emptyFile} alt="empty" />
|
||||||
|
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectForm;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./SelectForm";
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
export interface PropsSelectForm<OptionType> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -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<OptionType>({
|
||||||
|
text,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
isSearch = true,
|
||||||
|
showSelectedItems = false,
|
||||||
|
readOnly,
|
||||||
|
disabledItems = [],
|
||||||
|
selectedItems = [],
|
||||||
|
options = [],
|
||||||
|
title,
|
||||||
|
onClickSelect,
|
||||||
|
setSelectedItems,
|
||||||
|
getOptionLabel,
|
||||||
|
getOptionValue,
|
||||||
|
onRemove,
|
||||||
|
action,
|
||||||
|
renderOption,
|
||||||
|
selectedItemFields,
|
||||||
|
selectedItemFieldLabels,
|
||||||
|
}: PropsSelectMany<OptionType>) {
|
||||||
|
const refInputSearch = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [isFocus, setIsFocus] = useState(false);
|
||||||
|
const [listDataTerm, setListDataTerm] =
|
||||||
|
useState<Array<string | number>>(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 (
|
||||||
|
<Fragment>
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block mb-2 text-base font-medium">{label}</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Select box */}
|
||||||
|
<div
|
||||||
|
onClick={() => !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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 font-semibold",
|
||||||
|
selectedItems.length === 0 && "text-gray-400 font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0
|
||||||
|
? `Đã chọn ${selectedItems.length} ${text}`
|
||||||
|
: placeholder}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CirclePlus className="text-blue-700" size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected chips */}
|
||||||
|
{showSelectedItems === "default" && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{selectedItems
|
||||||
|
.map((v) => options.find((o) => getOptionValue(o) === v))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => {
|
||||||
|
const value = getOptionValue(item!);
|
||||||
|
const disabled = disabledItems.includes(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={value}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-3 py-1 rounded-full border text-sm",
|
||||||
|
disabled
|
||||||
|
? "bg-gray-100 border-gray-200 text-gray-400"
|
||||||
|
: "bg-blue-50 border-blue-200 text-blue-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{getOptionLabel(item!)}</span>
|
||||||
|
<X
|
||||||
|
size={16}
|
||||||
|
className={clsx(
|
||||||
|
"cursor-pointer",
|
||||||
|
disabled && "pointer-events-none opacity-50",
|
||||||
|
)}
|
||||||
|
onClick={() => handleRemoveSelected(value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Popup */}
|
||||||
|
<Popup open={isFocus} onClose={handleClose}>
|
||||||
|
<div className="relative w-[540px] max-h-[640px] bg-white rounded-2xl p-6 shadow-xl flex flex-col">
|
||||||
|
{/* Title */}
|
||||||
|
<h4 className="text-2xl font-semibold">{title || "Danh sách"}</h4>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
{isSearch && (
|
||||||
|
<div className="flex items-center gap-2 px-3 h-[42px] border border-gray-300 rounded-full mt-3">
|
||||||
|
<Search className="text-blue-700" size={18} />
|
||||||
|
<input
|
||||||
|
ref={refInputSearch}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
placeholder="Tìm kiếm..."
|
||||||
|
className="flex-1 outline-none bg-transparent text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Select all */}
|
||||||
|
{filteredOptions.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between mt-3 mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={listDataTerm.length === options.length}
|
||||||
|
onChange={(e) =>
|
||||||
|
setListDataTerm(
|
||||||
|
e.target.checked ? options.map(getOptionValue) : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-5 h-5 accent-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-500 font-medium">Chọn tất cả</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm">
|
||||||
|
Đã chọn:{" "}
|
||||||
|
<span className="font-semibold">{listDataTerm.length}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
<div className="max-h-[320px] overflow-y-auto border-t">
|
||||||
|
{filteredOptions.map((opt) => {
|
||||||
|
const value = getOptionValue(opt);
|
||||||
|
const checked = listDataTerm.includes(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(value)}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 border-b"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => handleOptionClick(opt)}
|
||||||
|
disabled={disabledItems.includes(value)}
|
||||||
|
className="w-5 h-5 accent-blue-500"
|
||||||
|
/>
|
||||||
|
<label className="flex-1 cursor-pointer text-base font-medium">
|
||||||
|
{getOptionLabel(opt)}
|
||||||
|
{renderOption?.(opt)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[280px] flex flex-col items-center justify-center">
|
||||||
|
<Image src={icons.emptyFile} alt="empty" />
|
||||||
|
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<CustomButton variant="grey" rounded="full" onClick={handleClose}>
|
||||||
|
Hủy bỏ
|
||||||
|
</CustomButton>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
disabled={listDataTerm.length === 0}
|
||||||
|
variant="midnightBlue"
|
||||||
|
rounded="full"
|
||||||
|
onClick={handleSaveData}
|
||||||
|
>
|
||||||
|
Xác nhận
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close */}
|
||||||
|
<div
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute top-6 right-6 cursor-pointer active:scale-90"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectMany;
|
||||||