first commit

This commit is contained in:
TuanVT
2026-05-23 09:48:07 +07:00
parent 03b9101bd5
commit b6ccde2c74
134 changed files with 7178 additions and 110 deletions
+64
View 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
View 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
View 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
View 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
View 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
View 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>
);
}
@@ -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
View 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
View 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
View 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
View 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
View 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
View 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;
@@ -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;
@@ -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 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
</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";
@@ -0,0 +1 @@
export { default } from "./BaseLayout";
@@ -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;
}
@@ -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,0 +1,5 @@
export interface PropsChartWrapper {
children: React.ReactNode;
isEmpty?: boolean;
message?: string;
}
@@ -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;
@@ -0,0 +1 @@
export { default } from "./GridColumn";
@@ -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;
}
@@ -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;
@@ -0,0 +1 @@
export { default } from "./LayoutAuth";
@@ -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;
@@ -0,0 +1 @@
export { default } from "./LayoutPages";
@@ -0,0 +1,8 @@
export interface PropsLayoutPages {
children: React.ReactNode;
listPages: {
title: string;
url: string;
color: string;
}[];
}
@@ -0,0 +1,8 @@
"use client";
import React from "react";
const MainPageHome = () => {
return <div>MainPageHome</div>;
};
export default MainPageHome;
@@ -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 OTP
</h3>
{/* DESCRIPTION */}
<p
className="
mt-1
text-center
text-[14px]
font-medium
text-[#6F767E]
"
>
Một 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 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 .
{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>
);
}
@@ -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,
}
@@ -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
</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>
);
}
@@ -0,0 +1 @@
export { default } from "./MainLogin";
@@ -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;
@@ -0,0 +1 @@
export { default } from "./LoadingTopBar";
@@ -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" />;
}
@@ -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;
@@ -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;
@@ -0,0 +1 @@
export { default } from "./SplashScreen";
@@ -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;
@@ -0,0 +1 @@
export { default } from "./SelectMany";
@@ -0,0 +1,27 @@
import { Dispatch, SetStateAction } from "react";
export interface PropsSelectMany<OptionType> {
text: string;
placeholder: string;
label?: string | React.ReactNode;
isSearch?: boolean;
readOnly?: boolean;
showSelectedItems?: false | "default" | "input";
disabledItems?: Array<string | number>;
selectedItems: Array<string | number>;
options: OptionType[];
title?: string;
onClickSelect?: () => void;
setSelectedItems?: Dispatch<SetStateAction<Array<string | number>>>;
getOptionLabel: (option: OptionType) => string | React.ReactNode;
getOptionValue: (option: OptionType) => string | number;
onRemove?: (item: string | number) => void;
action?: React.ReactNode;
renderOption?: (option: OptionType) => React.ReactNode;
selectedItemFields?: (keyof OptionType)[];
selectedItemFieldLabels?: Record<string, string>;
}
@@ -0,0 +1,206 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { X, Search } from "lucide-react";
import Image from "next/image";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
import icons from "@/constant/images/icons";
import Popup from "@/components/customs/custom-popup";
import CustomButton from "@/components/customs/custom-button";
import { PropsSelectSingle } from "./interface";
function SelectSingle<OptionType>({
text = "",
label,
placeholder = "Chọn 1 giá trị",
isSearch = true,
readOnly,
disabledItems = [],
selectedItem,
options,
title,
action,
getOptionLabel,
getOptionValue,
setSelectedItem,
onClickSelect,
renderOption,
}: PropsSelectSingle<OptionType>) {
const refInputSearch = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState("");
const [isFocus, setIsFocus] = useState(false);
const [selectedTemp, setSelectedTemp] = useState<string | number | null>(
null,
);
useEffect(() => {
if (isFocus && refInputSearch.current) {
setTimeout(() => refInputSearch.current?.focus(), 0);
}
}, [isFocus]);
const handlerFocused = () => {
if (onClickSelect) {
onClickSelect();
return;
}
if (readOnly) return;
// sync dữ liệu tại đây
setSelectedTemp(selectedItem ?? null);
setIsFocus(true);
};
const handleSelect = (value: string | number) => {
if (!disabledItems.includes(value)) {
setSelectedTemp(value);
}
};
const handleSave = () => {
setSelectedItem?.(selectedTemp);
setIsFocus(false);
};
const handleClose = () => {
setIsFocus(false);
setKeyword("");
setSelectedTemp(selectedItem ?? null);
};
const selectedLabel = (() => {
const selectedOption = options.find(
(opt) => getOptionValue(opt) === selectedItem,
);
return selectedOption
? getOptionLabel(selectedOption)
: `Đã chọn 1 ${text}`;
})();
const filteredOptions = options.filter((opt) =>
removeVietnameseTones(getOptionLabel(opt)).includes(
removeVietnameseTones(keyword),
),
);
return (
<div className="w-full">
{label && (
<label className="block mb-2 text-[16px] font-medium">{label}</label>
)}
{/* Select box */}
<div
onClick={handlerFocused}
className={clsx(
"h-12 px-4 flex items-center rounded-full border transition cursor-pointer",
isFocus ? "border-[#0011AB]" : "border-[#CDD5DF]",
readOnly &&
"bg-gray-100 border-gray-200 cursor-default pointer-events-none",
)}
>
<p
className={clsx(
"flex-1 text-[16px] font-semibold",
!selectedItem && "text-gray-400 font-medium",
)}
>
{selectedItem ? selectedLabel : placeholder}
</p>
</div>
{/* Popup */}
<Popup open={isFocus} onClose={handleClose}>
<div className="relative w-[640px] max-w-[90vw] max-h-[740px] bg-white rounded-2xl shadow-xl flex flex-col">
{/* Header */}
<div className="px-6 pt-4 border-b shadow-sm">
<h4 className="text-2xl font-semibold">
{title || "Chọn 1 giá trị"}
</h4>
{isSearch && (
<div className="flex items-center gap-4 mt-3 mb-3 flex-wrap">
<div className="flex items-center w-full h-[42px] px-3 border rounded-full border-gray-300">
<Search color="#0011AB" size={20} />
<input
ref={refInputSearch}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm kiếm..."
className="flex-1 ml-2 outline-none bg-transparent"
/>
</div>
{action && <div>{action}</div>}
</div>
)}
</div>
{/* List */}
{filteredOptions.length > 0 ? (
<div className="px-6 max-h-[420px] min-h-[280px] overflow-auto">
{filteredOptions.map((opt) => {
const value = getOptionValue(opt);
return (
<div
key={value}
className="flex items-center gap-3 py-3 border-b"
>
<input
type="radio"
checked={selectedTemp === value}
onChange={() => handleSelect(value)}
disabled={disabledItems.includes(value)}
className="w-5 h-5 accent-[#0011AB]"
/>
<label className="w-full text-[16px] font-medium cursor-pointer">
{getOptionLabel(opt)}
{renderOption && 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>
)}
{/* Footer */}
<div className="px-6 py-4 border-t flex justify-end gap-3 shadow-inner">
<CustomButton variant="grey" rounded="full" onClick={handleClose}>
Hủy bỏ
</CustomButton>
<CustomButton
variant="midnightBlue"
rounded="full"
disabled={selectedTemp === null}
onClick={handleSave}
>
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>
</div>
);
}
export default SelectSingle;
@@ -0,0 +1 @@
export { default } from "./SelectSingle";
@@ -0,0 +1,17 @@
export interface PropsSelectSingle<OptionType> {
text?: string;
label?: React.ReactNode;
placeholder?: string;
isSearch?: boolean;
readOnly?: boolean;
disabledItems?: (string | number)[];
selectedItem?: string | number | null;
options: OptionType[];
title?: string;
action?: React.ReactNode;
getOptionLabel: (opt: OptionType) => string;
getOptionValue: (opt: OptionType) => string | number;
setSelectedItem?: (value: string | number | null) => void;
renderOption?: (option: OptionType) => React.ReactNode;
onClickSelect?: () => void;
}
@@ -0,0 +1,192 @@
"use client";
import React, { useContext, useEffect, useState } from "react";
import clsx from "clsx";
import { PropsTextArea } from "./interface";
import { ContextFormCustom, IContextFormCustom } from "../../contexts";
function TextArea({
name,
value,
placeholder,
label,
isBlur = true,
showDone,
readOnly,
max,
min,
isRequired,
textRequired,
}: PropsTextArea) {
const [isFocus, setIsFocus] = useState(false);
const {
form,
countValidate,
errorText,
isDone,
setForm,
setValidate,
setErrorText,
} = useContext<IContextFormCustom<any>>(ContextFormCustom);
// =========================
// VALIDATE
// =========================
const handleValidate = (): boolean => {
const currentValue = `${form[name] || ""}`.trim();
if (isRequired && currentValue === "") return false;
if (min && currentValue.length < min) return false;
if (max && currentValue.length > max) return false;
return true;
};
// =========================
// ERROR MESSAGE
// =========================
const handleSetMessage = () => {
const currentValue = `${form[name] || ""}`.trim();
setErrorText((prev) => ({
...prev,
[name]: null,
}));
if (isRequired && currentValue === "") {
return setErrorText((prev) => ({
...prev,
[name]: textRequired || "Vui lòng nhập trường này",
}));
}
if (max && currentValue.length > max) {
return setErrorText((prev) => ({
...prev,
[name]: `Nhập tối đa ${max} kí tự`,
}));
}
if (min && currentValue.length < min) {
return setErrorText((prev) => ({
...prev,
[name]: `Nhập tối thiểu ${min} kí tự`,
}));
}
};
// =========================
// EFFECTS
// =========================
useEffect(() => {
if (countValidate > 0) {
handleSetMessage();
}
}, [countValidate]);
useEffect(() => {
setValidate((prev) => ({
...prev,
[name]: handleValidate(),
}));
}, [form[name]]);
// =========================
// EVENTS
// =========================
const handlerFocused = () => {
setIsFocus(true);
setErrorText((prev) => ({
...prev,
[name]: null,
}));
};
const handlerBlur = () => {
setIsFocus(false);
if (isBlur) {
handleSetMessage();
setValidate((prev) => ({
...prev,
[name]: handleValidate(),
}));
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value } = e.target;
setForm((prev: any) => ({
...prev,
[name]: value,
}));
};
return (
<div className="w-full space-y-1">
{/* LABEL */}
{label && (
<label
htmlFor={`textarea_${name}`}
className="block text-[16px] font-medium"
>
{label}
</label>
)}
{/* TEXTAREA */}
<textarea
id={`textarea_${name}`}
name={name}
value={value ?? form[name] ?? ""}
placeholder={placeholder}
autoComplete="off"
readOnly={readOnly}
disabled={readOnly}
onChange={handleChange}
onFocus={handlerFocused}
onBlur={handlerBlur}
className={clsx(
"w-full min-h-[140px] resize-none rounded-[20px] border px-4 py-3 text-[16px] font-medium outline-none transition",
// default
"border-[#CDD5DF] bg-white",
// hover
!readOnly && "hover:border-[#0019F6]",
// focus
isFocus && "border-[#0019F6]",
// error
errorText[name] && "border-[#E5493F]",
// done
showDone && isDone && "border-[#1CAD4C]",
// readonly
readOnly &&
"cursor-not-allowed border-gray-200 bg-gray-100 hover:border-gray-200",
)}
/>
{/* ERROR */}
{errorText[name] && (
<p className="text-[12px] text-red-500">{errorText[name]}</p>
)}
</div>
);
}
export default TextArea;
@@ -0,0 +1 @@
export { default } from "./TextArea";
@@ -0,0 +1,17 @@
export interface PropsTextArea {
name: string;
value?: string;
placeholder: string;
label?: string | React.ReactNode;
isBlur?: boolean;
showDone?: boolean;
readOnly?: boolean;
textRequired?: string;
max?: number;
min?: number;
isRequired?: boolean;
}
@@ -0,0 +1,23 @@
import { createContext, Dispatch, SetStateAction } from "react";
export interface IContextFormCustom<T> {
form: T;
setForm: Dispatch<SetStateAction<T>>;
errorText: Record<keyof T, string | null>;
setErrorText: Dispatch<SetStateAction<Record<keyof T, string | null>>>;
setValidate: Dispatch<SetStateAction<Record<keyof T, boolean> | null>>;
countValidate: number;
setCountValidate: Dispatch<SetStateAction<number>>;
isDone: boolean;
}
export const ContextFormCustom = createContext<IContextFormCustom<any>>({
form: {} as any,
setForm: () => {},
errorText: {},
setErrorText: () => {},
setValidate: () => {},
countValidate: 0,
setCountValidate: () => {},
isDone: false,
});
+1
View File
@@ -0,0 +1 @@
export { default } from "./FormCustom";
@@ -0,0 +1,8 @@
import { Dispatch, SetStateAction } from "react";
export interface PropsFormCustom<T extends Record<string, any>> {
children: React.ReactNode;
form: T;
setForm: Dispatch<SetStateAction<T>>;
onSubmit?: () => void;
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import React, { memo } from "react";
import Image from "next/image";
import clsx from "clsx";
import icons from "@/constant/images/icons";
// 👉 Types
export const ICON_ROUND_TYPES = [
"successPlay",
"success",
"error",
"errorEnd",
"errorGoods",
] as const;
export type IconRoundType = (typeof ICON_ROUND_TYPES)[number];
export interface PropsIconRound {
type?: IconRoundType;
icon?: React.ReactNode;
className?: string;
size?: number;
}
// 👉 Icon map
const iconMap = {
success: icons.tickCircle,
successPlay: icons.successPlay,
error: icons.errorWarning,
errorEnd: icons.errorEnd,
errorGoods: icons.errorGoods,
};
// 🎨 Background mapping
const outerBgMap: Record<IconRoundType, string> = {
success: "bg-green-100",
successPlay: "bg-green-100",
error: "bg-red-100",
errorEnd: "bg-red-100",
errorGoods: "bg-red-100",
};
const innerBgMap: Record<IconRoundType, string> = {
success: "bg-green-200",
successPlay: "bg-green-200",
error: "bg-red-200",
errorEnd: "bg-red-200",
errorGoods: "bg-red-200",
};
function IconRound({
type = "success",
icon,
className,
size = 24,
}: PropsIconRound) {
const renderDefaultIcon = () => {
const imgSrc = iconMap[type] ?? icons.tickCircle;
return (
<Image alt={`icon ${type}`} src={imgSrc} width={size} height={size} />
);
};
return (
<div
className={clsx(
"p-2 rounded-full flex items-center justify-center w-fit h-fit",
outerBgMap[type],
className,
)}
>
<div
className={clsx(
"flex items-center justify-center rounded-full p-2",
innerBgMap[type],
)}
style={{ width: 40, height: 40 }}
>
{icon ?? renderDefaultIcon()}
</div>
</div>
);
}
export default memo(IconRound);
@@ -0,0 +1,116 @@
"use client";
import { ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { PropsPositionContainer } from "./interface";
export default function PositionContainer({
children,
open,
onClose,
disableOverlay,
idParent,
classStyle,
}: PropsPositionContainer) {
const containerRef = useRef<HTMLDivElement | null>(null);
// ✅ create portal element ONCE
const [portalElement] = useState(() => {
if (typeof window === "undefined") {
return null;
}
return document.createElement("div");
});
// ================= APPEND PORTAL =================
useEffect(() => {
if (!portalElement) return;
const parent = idParent ? document.getElementById(idParent) : document.body;
if (!parent) return;
parent.appendChild(portalElement);
return () => {
parent.removeChild(portalElement);
};
}, [idParent, portalElement]);
// ================= CLICK OUTSIDE =================
useEffect(() => {
if (!disableOverlay || !open) return;
const handleMouseUp = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const insideClick = target.closest(".click");
if (
containerRef.current &&
!containerRef.current.contains(target) &&
!insideClick
) {
onClose();
}
};
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
};
}, [disableOverlay, open, onClose]);
// ================= SSR SAFE =================
if (!portalElement) return null;
return createPortal(
<div
className={clsx("fixed inset-0 z-[100]", !open && "pointer-events-none")}
>
{/* overlay */}
{!disableOverlay && (
<div
onClick={onClose}
className={clsx(
`
fixed inset-0
bg-black/50
transition-opacity
duration-300
`,
open ? "opacity-100" : "opacity-0",
)}
/>
)}
{/* panel */}
<div
ref={containerRef}
className={clsx(
`
fixed
top-0
right-0
h-full
z-[101]
transition-all
duration-300
ease-in-out
`,
open ? "translate-x-0 opacity-100" : "translate-x-full opacity-0",
classStyle?.main,
classStyle?.open,
)}
>
{children}
</div>
</div>,
portalElement,
);
}
@@ -0,0 +1,36 @@
"use client";
import { useEffect, useRef } from "react";
import { PropsClickOpen } from "./interface";
export default function ClickOpen({
children,
onClick,
duration = 100,
}: PropsClickOpen) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => {
// clear timeout cũ nếu spam click
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
onClick(false);
timeoutRef.current = setTimeout(() => {
onClick(true);
}, duration);
};
useEffect(() => {
return () => {
// cleanup khi unmount
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return <div onClick={handleClick}>{children}</div>;
}
@@ -0,0 +1 @@
export { default } from "./ClickOpen";
@@ -0,0 +1,5 @@
export interface PropsClickOpen {
children: any;
onClick: (bol: boolean) => void;
duration?: number;
}
@@ -0,0 +1,11 @@
export interface PropsPositionContainer {
children: any;
open: boolean;
onClose: () => void;
disableOverlay?: boolean;
idParent?: string;
classStyle?: {
main: string;
open: string;
};
}
@@ -0,0 +1,132 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import { Upload, Trash } from "lucide-react";
import { toastError, toastWarn } from "@/common/funcs/toast";
import { PropsUploadAvatar } from "./interface";
const MAXIMUM_FILE = 10;
export default function UploadAvatar({
path,
name,
onSetFile,
resetPath,
}: PropsUploadAvatar) {
const [imageUrl, setImageUrl] = useState<string>("");
const [fileName, setFileName] = useState<string>("");
const handleSelectImg = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const { size, type, name } = file;
if (size / 1024 / 1024 > MAXIMUM_FILE) {
toastError({ msg: `Kích thước tối đa ${MAXIMUM_FILE}MB` });
return;
}
if (!["image/jpeg", "image/jpg", "image/png"].includes(type)) {
toastWarn({
msg: "Chỉ hỗ trợ định dạng .jpg, .jpeg, .png",
});
return;
}
const url = URL.createObjectURL(file);
setImageUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
setFileName(name);
onSetFile?.(file);
};
useEffect(() => {
return () => {
if (imageUrl) URL.revokeObjectURL(imageUrl);
};
}, [imageUrl]);
const handleRemoveImg = () => {
if (imageUrl) URL.revokeObjectURL(imageUrl);
setImageUrl("");
setFileName("");
onSetFile?.(null);
resetPath?.();
};
const displayImage = imageUrl || path;
return (
<div className="flex flex-col md:flex-row gap-3">
{/* AVATAR */}
<Image
src={displayImage}
alt="avatar"
width={104}
height={104}
className="rounded-md border border-blue-500 object-cover"
/>
{/* RIGHT CONTENT */}
<div className="flex flex-col gap-3">
{/* CONTROL */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-3">
{/* UPLOAD INPUT */}
<label className="flex cursor-pointer select-none h-12 w-full md:w-auto">
{/* BUTTON */}
<div className="flex items-center justify-center gap-2 px-5 bg-gray-100 rounded-l-md shadow-inner">
<Upload size={18} />
<span className="text-sm font-semibold">Chọn file</span>
</div>
{/* FILE NAME */}
<div className="flex items-center px-5 w-full md:w-[328px] bg-blue-50 rounded-r-md shadow-inner overflow-hidden">
<p className="text-sm font-semibold text-gray-600 truncate w-full">
{fileName || "Tên file"}
</p>
</div>
<input
hidden
type="file"
name={name}
accept="image/png, image/jpeg, image/jpg"
onChange={handleSelectImg}
onClick={(e) => {
(e.target as HTMLInputElement).value = "";
}}
/>
</label>
{/* DELETE BUTTON */}
<button
onClick={handleRemoveImg}
className="flex items-center gap-2 px-6 h-12 bg-gray-100 rounded-md transition active:scale-95 hover:opacity-70"
>
<Trash size={18} color="#AF0000" />
<span className="text-sm font-semibold text-gray-700">
Gỡ nh đi diện
</span>
</button>
</div>
{/* DESCRIPTION */}
<p className="text-sm text-gray-400">
Hình nh dùng làm avatar, tối thiểu 300x300px
</p>
<p className="text-sm text-gray-400">
Đnh dạng hỗ trợ: JPG, JPEG, PNG
</p>
</div>
</div>
);
}
@@ -0,0 +1 @@
export { default } from "./UploadAvatar";
@@ -0,0 +1,8 @@
import { Dispatch, SetStateAction } from "react";
export interface PropsUploadAvatar {
path: any;
name: string;
onSetFile: Dispatch<SetStateAction<any>>;
resetPath?: () => void;
}