This commit is contained in:
2026-04-28 09:27:54 +07:00
commit 68d05da584
320 changed files with 26229 additions and 0 deletions
@@ -0,0 +1,86 @@
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Alert from "@/components/ui/alert/Alert";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Alerts | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Alerts page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
// other metadata
};
export default function Alerts() {
return (
<div>
<PageBreadcrumb pageTitle="Alerts" />
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Success Alert">
<Alert
variant="success"
title="Success Message"
message="Be cautious when performing this action."
showLink={true}
linkHref="/"
linkText="Learn more"
/>
<Alert
variant="success"
title="Success Message"
message="Be cautious when performing this action."
showLink={false}
/>
</ComponentCard>
<ComponentCard title="Warning Alert">
<Alert
variant="warning"
title="Warning Message"
message="Be cautious when performing this action."
showLink={true}
linkHref="/"
linkText="Learn more"
/>
<Alert
variant="warning"
title="Warning Message"
message="Be cautious when performing this action."
showLink={false}
/>
</ComponentCard>{" "}
<ComponentCard title="Error Alert">
<Alert
variant="error"
title="Error Message"
message="Be cautious when performing this action."
showLink={true}
linkHref="/"
linkText="Learn more"
/>
<Alert
variant="error"
title="Error Message"
message="Be cautious when performing this action."
showLink={false}
/>
</ComponentCard>{" "}
<ComponentCard title="Info Alert">
<Alert
variant="info"
title="Info Message"
message="Be cautious when performing this action."
showLink={true}
linkHref="/"
linkText="Learn more"
/>
<Alert
variant="info"
title="Info Message"
message="Be cautious when performing this action."
showLink={false}
/>
</ComponentCard>
</div>
</div>
);
}
@@ -0,0 +1,126 @@
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Avatar from "@/components/ui/avatar/Avatar";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Avatars | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Avatars page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
};
export default function AvatarPage() {
return (
<div>
<PageBreadcrumb pageTitle="Avatar" />
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Default Avatar">
{/* Default Avatar (No Status) */}
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
<Avatar src="/images/user/user-01.jpg" size="xsmall" />
<Avatar src="/images/user/user-01.jpg" size="small" />
<Avatar src="/images/user/user-01.jpg" size="medium" />
<Avatar src="/images/user/user-01.jpg" size="large" />
<Avatar src="/images/user/user-01.jpg" size="xlarge" />
<Avatar src="/images/user/user-01.jpg" size="xxlarge" />
</div>
</ComponentCard>
<ComponentCard title="Avatar with online indicator">
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
<Avatar
src="/images/user/user-01.jpg"
size="xsmall"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="small"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="medium"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="large"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xlarge"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xxlarge"
status="online"
/>
</div>
</ComponentCard>
<ComponentCard title="Avatar with Offline indicator">
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
<Avatar
src="/images/user/user-01.jpg"
size="xsmall"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="small"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="medium"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="large"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xlarge"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xxlarge"
status="offline"
/>
</div>
</ComponentCard>{" "}
<ComponentCard title="Avatar with busy indicator">
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
<Avatar
src="/images/user/user-01.jpg"
size="xsmall"
status="busy"
/>
<Avatar src="/images/user/user-01.jpg" size="small" status="busy" />
<Avatar
src="/images/user/user-01.jpg"
size="medium"
status="busy"
/>
<Avatar src="/images/user/user-01.jpg" size="large" status="busy" />
<Avatar
src="/images/user/user-01.jpg"
size="xlarge"
status="busy"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xxlarge"
status="busy"
/>
</div>
</ComponentCard>
</div>
</div>
);
}
@@ -0,0 +1,221 @@
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Badge from "@/components/ui/badge/Badge";
import { PlusIcon } from "@/icons";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Badge | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Badge page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
// other metadata
};
export default function BadgePage() {
return (
<div>
<PageBreadcrumb pageTitle="Badges" />
<div className="space-y-5 sm:space-y-6">
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
With Light Background
</h3>
</div>
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
{/* Light Variant */}
<Badge variant="light" color="primary">
Primary
</Badge>
<Badge variant="light" color="success">
Success
</Badge>{" "}
<Badge variant="light" color="error">
Error
</Badge>{" "}
<Badge variant="light" color="warning">
Warning
</Badge>{" "}
<Badge variant="light" color="info">
Info
</Badge>
<Badge variant="light" color="light">
Light
</Badge>
<Badge variant="light" color="dark">
Dark
</Badge>
</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
With Solid Background
</h3>
</div>
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
{/* Light Variant */}
<Badge variant="solid" color="primary">
Primary
</Badge>
<Badge variant="solid" color="success">
Success
</Badge>{" "}
<Badge variant="solid" color="error">
Error
</Badge>{" "}
<Badge variant="solid" color="warning">
Warning
</Badge>{" "}
<Badge variant="solid" color="info">
Info
</Badge>
<Badge variant="solid" color="light">
Light
</Badge>
<Badge variant="solid" color="dark">
Dark
</Badge>
</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Light Background with Left Icon
</h3>
</div>
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
<Badge variant="light" color="primary" startIcon={<PlusIcon />}>
Primary
</Badge>
<Badge variant="light" color="success" startIcon={<PlusIcon />}>
Success
</Badge>{" "}
<Badge variant="light" color="error" startIcon={<PlusIcon />}>
Error
</Badge>{" "}
<Badge variant="light" color="warning" startIcon={<PlusIcon />}>
Warning
</Badge>{" "}
<Badge variant="light" color="info" startIcon={<PlusIcon />}>
Info
</Badge>
<Badge variant="light" color="light" startIcon={<PlusIcon />}>
Light
</Badge>
<Badge variant="light" color="dark" startIcon={<PlusIcon />}>
Dark
</Badge>
</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Solid Background with Left Icon
</h3>
</div>
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
<Badge variant="solid" color="primary" startIcon={<PlusIcon />}>
Primary
</Badge>
<Badge variant="solid" color="success" startIcon={<PlusIcon />}>
Success
</Badge>{" "}
<Badge variant="solid" color="error" startIcon={<PlusIcon />}>
Error
</Badge>{" "}
<Badge variant="solid" color="warning" startIcon={<PlusIcon />}>
Warning
</Badge>{" "}
<Badge variant="solid" color="info" startIcon={<PlusIcon />}>
Info
</Badge>
<Badge variant="solid" color="light" startIcon={<PlusIcon />}>
Light
</Badge>
<Badge variant="solid" color="dark" startIcon={<PlusIcon />}>
Dark
</Badge>
</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Light Background with Right Icon
</h3>
</div>
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
<Badge variant="light" color="primary" endIcon={<PlusIcon />}>
Primary
</Badge>
<Badge variant="light" color="success" endIcon={<PlusIcon />}>
Success
</Badge>{" "}
<Badge variant="light" color="error" endIcon={<PlusIcon />}>
Error
</Badge>{" "}
<Badge variant="light" color="warning" endIcon={<PlusIcon />}>
Warning
</Badge>{" "}
<Badge variant="light" color="info" endIcon={<PlusIcon />}>
Info
</Badge>
<Badge variant="light" color="light" endIcon={<PlusIcon />}>
Light
</Badge>
<Badge variant="light" color="dark" endIcon={<PlusIcon />}>
Dark
</Badge>
</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Solid Background with Right Icon
</h3>
</div>
<div className="p-6 border-t border-gray-100 dark:border-gray-800 xl:p-10">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
<Badge variant="solid" color="primary" endIcon={<PlusIcon />}>
Primary
</Badge>
<Badge variant="solid" color="success" endIcon={<PlusIcon />}>
Success
</Badge>{" "}
<Badge variant="solid" color="error" endIcon={<PlusIcon />}>
Error
</Badge>{" "}
<Badge variant="solid" color="warning" endIcon={<PlusIcon />}>
Warning
</Badge>{" "}
<Badge variant="solid" color="info" endIcon={<PlusIcon />}>
Info
</Badge>
<Badge variant="solid" color="light" endIcon={<PlusIcon />}>
Light
</Badge>
<Badge variant="solid" color="dark" endIcon={<PlusIcon />}>
Dark
</Badge>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import Button from "@/components/ui/button/Button";
import { BoxIcon } from "@/icons";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Buttons | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Buttons page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
};
export default function Buttons() {
return (
<div>
<PageBreadcrumb pageTitle="Buttons" />
<div className="space-y-5 sm:space-y-6">
{/* Primary Button */}
<ComponentCard title="Primary Button">
<div className="flex items-center gap-5">
<Button size="sm" variant="primary">
Button Text
</Button>
<Button size="md" variant="primary">
Button Text
</Button>
</div>
</ComponentCard>
{/* Primary Button with Start Icon */}
<ComponentCard title="Primary Button with Left Icon">
<div className="flex items-center gap-5">
<Button size="sm" variant="primary" startIcon={<BoxIcon />}>
Button Text
</Button>
<Button size="md" variant="primary" startIcon={<BoxIcon />}>
Button Text
</Button>
</div>
</ComponentCard>{" "}
{/* Primary Button with Start Icon */}
<ComponentCard title="Primary Button with Right Icon">
<div className="flex items-center gap-5">
<Button size="sm" variant="primary" endIcon={<BoxIcon />}>
Button Text
</Button>
<Button size="md" variant="primary" endIcon={<BoxIcon />}>
Button Text
</Button>
</div>
</ComponentCard>
{/* Outline Button */}
<ComponentCard title="Secondary Button">
<div className="flex items-center gap-5">
{/* Outline Button */}
<Button size="sm" variant="outline">
Button Text
</Button>
<Button size="md" variant="outline">
Button Text
</Button>
</div>
</ComponentCard>
{/* Outline Button with Start Icon */}
<ComponentCard title="Outline Button with Left Icon">
<div className="flex items-center gap-5">
<Button size="sm" variant="outline" startIcon={<BoxIcon />}>
Button Text
</Button>
<Button size="md" variant="outline" startIcon={<BoxIcon />}>
Button Text
</Button>
</div>
</ComponentCard>{" "}
{/* Outline Button with Start Icon */}
<ComponentCard title="Outline Button with Right Icon">
<div className="flex items-center gap-5">
<Button size="sm" variant="outline" endIcon={<BoxIcon />}>
Button Text
</Button>
<Button size="md" variant="outline" endIcon={<BoxIcon />}>
Button Text
</Button>
</div>
</ComponentCard>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import ResponsiveImage from "@/components/ui/images/ResponsiveImage";
import ThreeColumnImageGrid from "@/components/ui/images/ThreeColumnImageGrid";
import TwoColumnImageGrid from "@/components/ui/images/TwoColumnImageGrid";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Images | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Images page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
// other metadata
};
export default function Images() {
return (
<div>
<PageBreadcrumb pageTitle="Images" />
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Responsive image">
<ResponsiveImage />
</ComponentCard>
<ComponentCard title="Image in 2 Grid">
<TwoColumnImageGrid />
</ComponentCard>
<ComponentCard title="Image in 3 Grid">
<ThreeColumnImageGrid />
</ComponentCard>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import DefaultModal from "@/components/example/ModalExample/DefaultModal";
import FormInModal from "@/components/example/ModalExample/FormInModal";
import FullScreenModal from "@/components/example/ModalExample/FullScreenModal";
import ModalBasedAlerts from "@/components/example/ModalExample/ModalBasedAlerts";
import VerticallyCenteredModal from "@/components/example/ModalExample/VerticallyCenteredModal";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Modals | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Modals page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
// other metadata
};
export default function Modals() {
return (
<div>
<PageBreadcrumb pageTitle="Modals" />
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2 xl:gap-6">
<DefaultModal />
<VerticallyCenteredModal />
<FormInModal />
<FullScreenModal />
<ModalBasedAlerts />
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import VideosExample from "@/components/ui/video/VideosExample";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Videos | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Videos page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
};
export default function VideoPage() {
return (
<div>
<PageBreadcrumb pageTitle="Videos" />
<VideosExample />
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useSidebar } from "@/context/SidebarContext";
import AppHeader from "@/layout/AppHeader";
import AppSidebar from "@/layout/AppSidebar";
import Backdrop from "@/layout/Backdrop";
import { apiGetCurrentUser } from "@/service/auth";
import { setUserData } from "@/store/features/userSlice";
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
const dispatch = useDispatch()
useEffect(() => {
const fetchUser = async () => {
try {
const userData = await apiGetCurrentUser();
dispatch(setUserData(userData.data));
} catch (err) {
console.error("Lỗi:", err);
}
};
fetchUser();
}, [])
// Dynamic class for main content margin based on sidebar state
const mainContentMargin = isMobileOpen
? "ml-0"
: isExpanded || isHovered
? "lg:ml-[290px]"
: "lg:ml-[90px]";
return (
<div className="min-h-screen xl:flex">
{/* Sidebar and Backdrop */}
<AppSidebar />
<Backdrop />
{/* Main Content Area */}
<div
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
>
{/* Header */}
<AppHeader />
{/* Page Content */}
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
import React from "react";
import MonthlyTarget from "@/components/ecommerce/MonthlyTarget";
import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart";
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
import RecentOrders from "@/components/ecommerce/RecentOrders";
import DemographicCard from "@/components/ecommerce/DemographicCard";
export const metadata: Metadata = {
title:
"Admin Dashboard",
description: "This is Dashboard Home for History Web",
};
export default function Ecommerce() {
return (
<div className="grid grid-cols-12 gap-4 md:gap-6">
<div className="col-span-12 space-y-6 xl:col-span-7">
<EcommerceMetrics />
<MonthlySalesChart />
</div>
<div className="col-span-12 xl:col-span-5">
<MonthlyTarget />
</div>
<div className="col-span-12">
<StatisticsChart />
</div>
<div className="col-span-12 xl:col-span-5">
<DemographicCard />
</div>
<div className="col-span-12 xl:col-span-7">
<RecentOrders />
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import GridShape from "@/components/common/GridShape";
import ThemeTogglerTwo from "@/components/common/ThemeTogglerTwo";
import { ThemeProvider } from "@/context/ThemeContext";
import Image from "next/image";
import Link from "next/link";
import React from "react";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative p-6 bg-white z-1 dark:bg-gray-900 sm:p-0">
<ThemeProvider>
<div className="relative flex lg:flex-row w-full h-screen justify-center flex-col dark:bg-gray-900 sm:p-0">
{children}
{/* <div className="lg:w-1/2 w-full h-full bg-brand-950 dark:bg-white/5 lg:grid items-center hidden">
<div className="relative items-center justify-center flex z-1">
<GridShape />
<div className="flex flex-col items-center max-w-xs">
<Link href="/" className="block mb-4">
<Image
width={231}
height={48}
src="./images/logo/auth-logo.svg"
alt="Logo"
/>
</Link>
<p className="text-center text-gray-400 dark:text-white/60">
Free and Open-Source Tailwind CSS Admin Dashboard Template
</p>
</div>
</div>
</div> */}
<div className="fixed bottom-6 right-6 z-50 hidden sm:block">
<ThemeTogglerTwo />
</div>
</div>
</ThemeProvider>
</div>
);
}
@@ -0,0 +1,239 @@
"use client";
import Input from "@/components/form/input/InputField";
import Label from "@/components/form/Label";
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "@/icons";
import { apiCreateOTP, apiVerifyOTP, apiResetPassword } from "@/service/auth";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
export default function ResetPasswordForm() {
const [step, setStep] = useState(1);
const [email, setEmail] = useState("");
const [otp, setOtp] = useState("");
const [newPassword, setNewPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [loading, setLoading] = useState(false);
const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const isValidPassword = (pass: string) => {
const passwordRegex = /^(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/;
return passwordRegex.test(pass);
};
const handleSendOtp = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMsg("");
if (!email.trim()) {
setErrorMsg("Vui lòng nhập email.");
return;
}
if (!isValidEmail(email)) {
setErrorMsg("Email không đúng định dạng.");
return;
}
try {
setLoading(true);
await apiCreateOTP(email);
toast.success("Mã OTP đã được gửi đến email của bạn!");
setStep(2);
} catch (error) {
setErrorMsg("Lỗi khi gửi OTP. Vui lòng kiểm tra lại email.");
toast.error("Gửi OTP thất bại.");
} finally {
setLoading(false);
}
};
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMsg("");
if (!otp.trim()) {
setErrorMsg("Vui lòng nhập mã OTP.");
return;
}
if (!isValidPassword(newPassword)) {
setErrorMsg("Mật khẩu chưa đủ điều kiện bảo mật.");
return;
}
try {
setLoading(true);
const verifyRes = await apiVerifyOTP(email, otp);
const tokenId = verifyRes?.data?.token_id;
if (!tokenId) {
throw new Error("OTP không hợp lệ hoặc đã hết hạn.");
}
const resetPayload = {
email: email,
new_password: newPassword,
token_id: tokenId,
};
await apiResetPassword(resetPayload);
toast.success("Đổi mật khẩu thành công!");
window.location.href = "/signin";
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Đổi mật khẩu thất bại.";
setErrorMsg(errorMessage);
console.error("Reset password error:", error);
toast.error("Vui lòng kiểm tra lại mã OTP.");
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col flex-1 w-full lg:w-1/2 overflow-y-auto no-scrollbar">
<div className="w-full max-w-md mx-auto mb-5 sm:pt-10">
<Link
href="/signin"
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<ChevronLeftIcon />
Back to Sign In
</Link>
</div>
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
<div>
<div className="mb-5 sm:mb-8">
<h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
{step === 1 ? "Forgot Password" : "Set New Password"}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{step === 1
? "Enter your email address to receive an OTP code."
: `We sent an OTP to ${email}. Please enter it along with your new password.`}
</p>
</div>
{errorMsg && (
<div className="p-3 mb-4 text-sm rounded text-error-500 bg-error-50 dark:bg-error-500/10">
{errorMsg}
</div>
)}
{step === 1 && (
<form onSubmit={handleSendOtp}>
<div className="space-y-5">
<div>
<Label>
Email <span className="text-error-500">*</span>
</Label>
<Input
type="email"
name="email"
defaultValue={email}
onChange={(e) => {
setEmail(e.target.value);
setErrorMsg("");
}}
placeholder="Enter your registered email"
/>
</div>
<div>
<button
type="submit"
disabled={loading || !email.trim()}
className={`flex items-center justify-center w-full px-4 py-3 text-sm font-medium text-white transition rounded-lg shadow-theme-xs
${loading || !email.trim() ? "bg-gray-400 cursor-not-allowed" : "bg-brand-500 hover:bg-brand-600"}`}
>
{loading ? "Sending OTP..." : "Send Reset Code"}
</button>
</div>
</div>
</form>
)}
{step === 2 && (
<form onSubmit={handleResetPassword}>
<div className="space-y-5">
<div>
<Label>
OTP Code <span className="text-error-500">*</span>
</Label>
<Input
type="text"
name="otp"
defaultValue={otp}
onChange={(e) => {
setOtp(e.target.value);
setErrorMsg("");
}}
placeholder="Enter the 6-digit code"
/>
</div>
<div>
<Label>
New Password <span className="text-error-500">*</span>
</Label>
<div
className={`relative ${newPassword.length > 0 && !isValidPassword(newPassword) ? "border border-red-500 ring-1 ring-red-500 rounded-lg" : ""}`}
>
<Input
name="newPassword"
defaultValue={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setErrorMsg("");
}}
placeholder="Min. 8 characters"
type={showPassword ? "text" : "password"}
/>
<span
onClick={() => setShowPassword(!showPassword)}
className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
>
{showPassword ? <EyeIcon /> : <EyeCloseIcon />}
</span>
</div>
<p
className={`mt-2 text-xs ${newPassword.length === 0 ? "text-gray-400" : isValidPassword(newPassword) ? "text-green-500" : "text-red-500"}`}
>
Mật khẩu phải chứa tối thiểu 8 tự, 1 chữ cái in hoa, 1 chữ số 1 tự đc biệt.
</p>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setStep(1)}
className="flex items-center justify-center w-1/3 px-4 py-3 text-sm font-medium text-gray-700 transition bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Back
</button>
<button
type="submit"
disabled={loading || !isValidPassword(newPassword)}
className={`flex items-center justify-center w-2/3 px-4 py-3 text-sm font-medium text-white transition rounded-lg shadow-theme-xs
${loading || !isValidPassword(newPassword) ? "bg-brand-400 opacity-70 cursor-not-allowed" : "bg-brand-500 hover:bg-brand-600"}`}
>
{loading ? "Resetting..." : "Reset Password"}
</button>
</div>
</div>
</form>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,11 @@
import SignInForm from "@/components/auth/SignInForm";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Next.js SignIn Page | TailAdmin - Next.js Dashboard Template",
description: "This is Next.js Signin Page TailAdmin Dashboard Template",
};
export default function SignIn() {
return <SignInForm />;
}
@@ -0,0 +1,12 @@
import SignUpForm from "@/components/auth/SignUpForm";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Next.js SignUp Page | TailAdmin - Next.js Dashboard Template",
description: "This is Next.js SignUp Page TailAdmin Dashboard Template",
// other metadata
};
export default function SignUp() {
return <SignUpForm />;
}
@@ -0,0 +1,54 @@
import GridShape from "@/components/common/GridShape";
import { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Error 404 | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Error 404 page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
};
export default function Error404() {
return (
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
<GridShape />
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
ERROR
</h1>
<Image
src="/images/error/404.svg"
alt="404"
className="dark:hidden"
width={472}
height={152}
/>
<Image
src="/images/error/404-dark.svg"
alt="404"
className="hidden dark:block"
width={472}
height={152}
/>
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
We cant seem to find the page you are looking for!
</p>
<Link
href="/"
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
Back to Home Page
</Link>
</div>
{/* <!-- Footer --> */}
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
&copy; {new Date().getFullYear()} - TailAdmin
</p>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function FullWidthPageLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div>{children}</div>;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+822
View File
@@ -0,0 +1,822 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme {
--font-*: initial;
--font-outfit: Outfit, sans-serif;
--font-inter: "Inter", ui-sans-serif, system-ui, sans-serif;
--breakpoint-*: initial;
--breakpoint-2xsm: 375px;
--breakpoint-xsm: 425px;
--breakpoint-3xl: 2000px;
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
--text-title-2xl: 72px;
--text-title-2xl--line-height: 90px;
--text-title-xl: 60px;
--text-title-xl--line-height: 72px;
--text-title-lg: 48px;
--text-title-lg--line-height: 60px;
--text-title-md: 36px;
--text-title-md--line-height: 44px;
--text-title-sm: 30px;
--text-title-sm--line-height: 38px;
--text-theme-xl: 20px;
--text-theme-xl--line-height: 30px;
--text-theme-sm: 14px;
--text-theme-sm--line-height: 20px;
--text-theme-xs: 12px;
--text-theme-xs--line-height: 18px;
--color-current: currentColor;
--color-transparent: transparent;
--color-white: #ffffff;
--color-black: #101828;
--color-brand-25: #f2f7ff;
--color-brand-50: #ecf3ff;
--color-brand-100: #dde9ff;
--color-brand-200: #c2d6ff;
--color-brand-300: #9cb9ff;
--color-brand-400: #7592ff;
--color-brand-500: #465fff;
--color-brand-600: #3641f5;
--color-brand-700: #2a31d8;
--color-brand-800: #252dae;
--color-brand-900: #262e89;
--color-brand-950: #161950;
--color-blue-light-25: #f5fbff;
--color-blue-light-50: #f0f9ff;
--color-blue-light-100: #e0f2fe;
--color-blue-light-200: #b9e6fe;
--color-blue-light-300: #7cd4fd;
--color-blue-light-400: #36bffa;
--color-blue-light-500: #0ba5ec;
--color-blue-light-600: #0086c9;
--color-blue-light-700: #026aa2;
--color-blue-light-800: #065986;
--color-blue-light-900: #0b4a6f;
--color-blue-light-950: #062c41;
--color-gray-25: #fcfcfd;
--color-gray-50: #f9fafb;
--color-gray-100: #f2f4f7;
--color-gray-200: #e4e7ec;
--color-gray-300: #d0d5dd;
--color-gray-400: #98a2b3;
--color-gray-500: #667085;
--color-gray-600: #475467;
--color-gray-700: #344054;
--color-gray-800: #1d2939;
--color-gray-900: #101828;
--color-gray-950: #0c111d;
--color-gray-dark: #1a2231;
--color-orange-25: #fffaf5;
--color-orange-50: #fff6ed;
--color-orange-100: #ffead5;
--color-orange-200: #fddcab;
--color-orange-300: #feb273;
--color-orange-400: #fd853a;
--color-orange-500: #fb6514;
--color-orange-600: #ec4a0a;
--color-orange-700: #c4320a;
--color-orange-800: #9c2a10;
--color-orange-900: #7e2410;
--color-orange-950: #511c10;
--color-success-25: #f6fef9;
--color-success-50: #ecfdf3;
--color-success-100: #d1fadf;
--color-success-200: #a6f4c5;
--color-success-300: #6ce9a6;
--color-success-400: #32d583;
--color-success-500: #12b76a;
--color-success-600: #039855;
--color-success-700: #027a48;
--color-success-800: #05603a;
--color-success-900: #054f31;
--color-success-950: #053321;
--color-error-25: #fffbfa;
--color-error-50: #fef3f2;
--color-error-100: #fee4e2;
--color-error-200: #fecdca;
--color-error-300: #fda29b;
--color-error-400: #f97066;
--color-error-500: #f04438;
--color-error-600: #d92d20;
--color-error-700: #b42318;
--color-error-800: #912018;
--color-error-900: #7a271a;
--color-error-950: #55160c;
--color-warning-25: #fffcf5;
--color-warning-50: #fffaeb;
--color-warning-100: #fef0c7;
--color-warning-200: #fedf89;
--color-warning-300: #fec84b;
--color-warning-400: #fdb022;
--color-warning-500: #f79009;
--color-warning-600: #dc6803;
--color-warning-700: #b54708;
--color-warning-800: #93370d;
--color-warning-900: #7a2e0e;
--color-warning-950: #4e1d09;
--color-theme-pink-500: #ee46bc;
--color-theme-purple-500: #7a5af8;
--shadow-theme-md:
0px 4px 8px -2px rgba(16, 24, 40, 0.1),
0px 2px 4px -2px rgba(16, 24, 40, 0.06);
--shadow-theme-lg:
0px 12px 16px -4px rgba(16, 24, 40, 0.08),
0px 4px 6px -2px rgba(16, 24, 40, 0.03);
--shadow-theme-sm:
0px 1px 3px 0px rgba(16, 24, 40, 0.1),
0px 1px 2px 0px rgba(16, 24, 40, 0.06);
--shadow-theme-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
--shadow-theme-xl:
0px 20px 24px -4px rgba(16, 24, 40, 0.08),
0px 8px 8px -4px rgba(16, 24, 40, 0.03);
--shadow-datepicker: -5px 0 0 #262d3c, 5px 0 0 #262d3c;
--shadow-focus-ring: 0px 0px 0px 4px rgba(70, 95, 255, 0.12);
--shadow-slider-navigation:
0px 1px 2px 0px rgba(16, 24, 40, 0.1), 0px 1px 3px 0px rgba(16, 24, 40, 0.1);
--shadow-tooltip:
0px 4px 6px -2px rgba(16, 24, 40, 0.05),
-8px 0px 20px 8px rgba(16, 24, 40, 0.05);
--drop-shadow-4xl:
0 35px 35px rgba(0, 0, 0, 0.25), 0 45px 65px rgba(0, 0, 0, 0.15);
--z-index-1: 1;
--z-index-9: 9;
--z-index-99: 99;
--z-index-999: 999;
--z-index-9999: 9999;
--z-index-99999: 99999;
--z-index-999999: 999999;
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
body {
@apply relative font-normal font-inter z-1 bg-gray-50;
}
}
@utility menu-item {
@apply relative flex items-center w-full gap-3 px-3 py-2 font-medium rounded-lg text-theme-sm;
}
@utility menu-item-active {
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
}
@utility menu-item-inactive {
@apply text-gray-700 hover:bg-gray-100 group-hover:text-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-gray-300;
}
@utility menu-item-icon {
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400;
}
@utility menu-item-icon-active {
@apply text-brand-500 dark:text-brand-400;
}
@utility menu-item-icon-inactive {
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
}
@utility menu-item-arrow {
@apply relative;
}
@utility menu-item-arrow-active {
@apply rotate-180 text-brand-500 dark:text-brand-400;
}
@utility menu-item-arrow-inactive {
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
}
@utility menu-dropdown-item {
@apply relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-theme-sm font-medium;
}
@utility menu-dropdown-item-active {
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
}
@utility menu-dropdown-item-inactive {
@apply text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5;
}
@utility menu-dropdown-badge {
@apply block rounded-full px-2.5 py-0.5 text-xs font-medium uppercase text-brand-500 dark:text-brand-400;
}
@utility menu-dropdown-badge-active {
@apply bg-brand-100 dark:bg-brand-500/20;
}
@utility menu-dropdown-badge-inactive {
@apply bg-brand-50 group-hover:bg-brand-100 dark:bg-brand-500/15 dark:group-hover:bg-brand-500/20;
}
@utility no-scrollbar {
/* Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@utility custom-scrollbar {
&::-webkit-scrollbar {
@apply size-1.5;
}
&::-webkit-scrollbar-track {
@apply rounded-full;
}
&::-webkit-scrollbar-thumb {
@apply bg-gray-200 rounded-full dark:bg-gray-700;
}
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #344054;
}
/* third-party libraries CSS */
.apexcharts-legend-text {
@apply !text-gray-700 dark:!text-gray-400;
}
.apexcharts-text {
@apply !fill-gray-700 dark:!fill-gray-400;
}
.apexcharts-tooltip.apexcharts-theme-light {
@apply gap-1 !rounded-lg !border-gray-200 p-3 !shadow-theme-sm dark:!border-gray-800 dark:!bg-gray-900;
}
.apexcharts-legend-text {
@apply pl-5! !text-gray-700 dark:!text-gray-400;
}
.apexcharts-tooltip-series-group {
@apply !p-0;
}
.apexcharts-tooltip-y-group {
@apply !p-0;
}
.apexcharts-tooltip-title {
@apply !mb-0 !border-b-0 !bg-transparent !p-0 !text-[10px] !leading-4 !text-gray-800 dark:!text-white/90;
}
.apexcharts-tooltip-text {
@apply !text-theme-xs !text-gray-700 dark:!text-white/90;
}
.apexcharts-tooltip-text-y-value {
@apply !font-medium;
}
.apexcharts-gridline {
@apply !stroke-gray-100 dark:!stroke-gray-800;
}
.flatpickr-wrapper {
@apply w-full;
}
.flatpickr-calendar {
@apply mt-2 !bg-white !rounded-xl !p-5 !border !border-gray-200 dark:!border-gray-800 !text-gray-500 dark:!bg-gray-dark dark:!text-gray-400 dark:!shadow-theme-xl 2xsm:!w-auto;
}
.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg {
@apply stroke-brand-500;
}
.flatpickr-calendar.arrowTop:before,
.flatpickr-calendar.arrowTop:after {
@apply hidden;
}
.flatpickr-current-month {
@apply !p-0;
}
.flatpickr-current-month .cur-month,
.flatpickr-current-month input.cur-year {
@apply !h-auto !pt-0 !text-lg !font-medium !text-gray-800 dark:!text-white/90;
}
.flatpickr-prev-month,
.flatpickr-next-month {
@apply !p-0;
}
.flatpickr-weekdays {
@apply h-auto mt-6 mb-4 !bg-transparent;
}
.flatpickr-calendar {
@apply mt-2 !bg-white !rounded-xl !p-5 !shadow-none !border !border-gray-200 dark:!border-white/5 !text-gray-500 dark:!bg-gray-dark dark:!text-gray-400 dark:!shadow-theme-xl 2xsm:!w-auto;
}
@media (max-width: 525px) {
.flatpickr-calendar.static {
left: auto !important;
right: 0 !important;
margin-right: -30px;
}
}
.flatpickr-weekday {
@apply !text-theme-sm !font-medium !text-gray-500 dark:!text-gray-400 !bg-transparent;
}
.flatpickr-day {
@apply !flex !items-center !text-theme-sm !font-medium !text-gray-800 dark:!text-white/90 dark:hover:!border-gray-300 dark:hover:!bg-gray-900;
}
.flatpickr-day.nextMonthDay,
.flatpickr-day.prevMonthDay {
@apply !text-gray-400;
}
.flatpickr-months > .flatpickr-month {
background: none !important;
}
.flatpickr-month .flatpickr-current-month .flatpickr-monthDropdown-months {
background: none !important;
appearance: none;
background-image: none !important;
font-weight: 500;
}
.flatpickr-month
.flatpickr-current-month
.flatpickr-monthDropdown-months:focus {
outline: none !important;
border: 0 !important;
}
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
@apply !top-7 dark:!fill-white dark:!text-white !bg-transparent;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
@apply !left-7;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
@apply !right-7;
}
.flatpickr-days {
@apply !border-0;
}
span.flatpickr-weekday,
.flatpickr-months .flatpickr-month {
@apply dark:!fill-white dark:!text-white !bg-none;
}
.flatpickr-innerContainer {
@apply !border-b-0;
}
.flatpickr-months .flatpickr-month {
@apply !bg-none;
}
.flatpickr-day.inRange {
box-shadow: -5px 0 0 #f9fafb, 5px 0 0 #f9fafb !important;
@apply dark:!shadow-datepicker;
}
.flatpickr-day.inRange,
.flatpickr-day.prevMonthDay.inRange,
.flatpickr-day.nextMonthDay.inRange,
.flatpickr-day.today.inRange,
.flatpickr-day.prevMonthDay.today.inRange,
.flatpickr-day.nextMonthDay.today.inRange,
.flatpickr-day:hover,
.flatpickr-day.prevMonthDay:hover,
.flatpickr-day.nextMonthDay:hover,
.flatpickr-day:focus,
.flatpickr-day.prevMonthDay:focus,
.flatpickr-day.nextMonthDay:focus {
@apply !border-gray-50 !bg-gray-50 dark:!border-0 dark:!border-white/5 dark:!bg-white/5;
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.selected,
.flatpickr-day.endRange {
@apply !text-white dark:!text-white;
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange,
.flatpickr-day.selected.inRange,
.flatpickr-day.startRange.inRange,
.flatpickr-day.endRange.inRange,
.flatpickr-day.selected:focus,
.flatpickr-day.startRange:focus,
.flatpickr-day.endRange:focus,
.flatpickr-day.selected:hover,
.flatpickr-day.startRange:hover,
.flatpickr-day.endRange:hover,
.flatpickr-day.selected.prevMonthDay,
.flatpickr-day.startRange.prevMonthDay,
.flatpickr-day.endRange.prevMonthDay,
.flatpickr-day.selected.nextMonthDay,
.flatpickr-day.startRange.nextMonthDay,
.flatpickr-day.endRange.nextMonthDay {
background: #465fff;
@apply !border-brand-500 !bg-brand-500 hover:!border-brand-50 hover:!bg-brand-500;
}
.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n + 1)),
.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n + 1)),
.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n + 1)) {
box-shadow: -10px 0 0 #465fff;
}
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg,
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
@apply hover:!fill-none;
}
.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg {
fill: none !important;
}
.flatpickr-calendar.static {
@apply right-0;
}
.fc .fc-view-harness {
@apply max-w-full overflow-x-auto custom-scrollbar;
}
.fc-dayGridMonth-view.fc-view.fc-daygrid {
@apply min-w-[718px];
}
.fc .fc-scrollgrid-section > * {
border-right-width: 0;
border-bottom-width: 0;
}
.fc .fc-scrollgrid {
border-left-width: 0;
}
.fc .fc-toolbar.fc-header-toolbar {
@apply flex-col gap-4 px-6 pt-6 sm:flex-row;
}
.fc-button-group {
@apply gap-2;
}
.fc-button-group .fc-button {
@apply flex h-10 w-10 items-center justify-center !rounded-lg border border-gray-200 bg-transparent hover:border-gray-200 hover:bg-gray-50 focus:shadow-none active:!border-gray-200 active:!bg-transparent active:!shadow-none dark:border-gray-800 dark:hover:border-gray-800 dark:hover:bg-gray-900 dark:active:border-gray-800!;
}
.fc-button-group .fc-button.fc-prev-button:before {
@apply inline-block mt-1;
content: url("data:image/svg+xml,%3Csvg width='25' height='24' viewBox='0 0 25 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16.0068 6L9.75684 12.25L16.0068 18.5' stroke='%23344054' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");
}
.fc-button-group .fc-button.fc-next-button:before {
@apply inline-block mt-1;
content: url("data:image/svg+xml,%3Csvg width='25' height='24' viewBox='0 0 25 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.50684 19L15.7568 12.75L9.50684 6.5' stroke='%23344054' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");
}
.dark .fc-button-group .fc-button.fc-prev-button:before {
content: url("data:image/svg+xml,%3Csvg width='25' height='24' viewBox='0 0 25 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16.0068 6L9.75684 12.25L16.0068 18.5' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");
}
.dark .fc-button-group .fc-button.fc-next-button:before {
content: url("data:image/svg+xml,%3Csvg width='25' height='24' viewBox='0 0 25 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.50684 19L15.7568 12.75L9.50684 6.5' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");
}
.fc-button-group .fc-button .fc-icon {
@apply hidden;
}
.fc-addEventButton-button {
@apply !rounded-lg !border-0 !bg-brand-500 !px-4 !py-2.5 !text-sm !font-medium hover:!bg-brand-600 focus:!shadow-none;
}
.fc-toolbar-title {
@apply !text-lg !font-medium text-gray-800 dark:text-white/90;
}
.fc-header-toolbar.fc-toolbar .fc-toolbar-chunk:last-child {
@apply rounded-lg bg-gray-100 p-0.5 dark:bg-gray-900;
}
.fc-header-toolbar.fc-toolbar .fc-toolbar-chunk:last-child .fc-button {
@apply !h-auto !w-auto rounded-md !border-0 bg-transparent !px-5 !py-2 text-sm font-medium text-gray-500 hover:text-gray-700 focus:shadow-none! dark:text-gray-400;
}
.fc-header-toolbar.fc-toolbar
.fc-toolbar-chunk:last-child
.fc-button.fc-button-active {
@apply text-gray-900 bg-white dark:bg-gray-800 dark:text-white;
}
.fc-theme-standard th {
@apply !border-x-0 border-t !border-gray-200 bg-gray-50 !text-left dark:!border-gray-800 dark:bg-gray-900;
}
.fc-theme-standard td,
.fc-theme-standard .fc-scrollgrid {
@apply !border-gray-200 dark:!border-gray-800;
}
.fc .fc-col-header-cell-cushion {
@apply !px-5 !py-4 text-sm font-medium uppercase text-gray-400;
}
.fc .fc-daygrid-day.fc-day-today {
@apply bg-transparent;
}
.fc .fc-daygrid-day {
@apply p-2;
}
.fc .fc-daygrid-day.fc-day-today .fc-scrollgrid-sync-inner {
@apply rounded-sm bg-gray-100 dark:bg-white/[0.03];
}
.fc .fc-daygrid-day-number {
@apply !p-3 text-sm font-medium text-gray-700 dark:text-gray-400;
}
.fc .fc-daygrid-day-top {
@apply !flex-row;
}
.fc .fc-day-other .fc-daygrid-day-top {
opacity: 1;
}
.fc .fc-day-other .fc-daygrid-day-top .fc-daygrid-day-number {
@apply text-gray-400 dark:text-white/30;
}
.event-fc-color {
@apply rounded-lg py-2.5 pl-4 pr-3;
}
.event-fc-color .fc-event-title {
@apply p-0 text-sm font-normal text-gray-700;
}
.fc-daygrid-event-dot {
@apply w-1 h-5 ml-0 mr-3 border-none rounded-sm;
}
.fc-event {
@apply focus:shadow-none;
}
.fc-daygrid-event.fc-event-start {
@apply !ml-3;
}
.event-fc-color.fc-bg-success {
@apply border-success-50 bg-success-50;
}
.event-fc-color.fc-bg-danger {
@apply border-error-50 bg-error-50;
}
.event-fc-color.fc-bg-primary {
@apply border-brand-50 bg-brand-50;
}
.event-fc-color.fc-bg-warning {
@apply border-orange-50 bg-orange-50;
}
.event-fc-color.fc-bg-success .fc-daygrid-event-dot {
@apply bg-success-500;
}
.event-fc-color.fc-bg-danger .fc-daygrid-event-dot {
@apply bg-error-500;
}
.event-fc-color.fc-bg-primary .fc-daygrid-event-dot {
@apply bg-brand-500;
}
.event-fc-color.fc-bg-warning .fc-daygrid-event-dot {
@apply bg-orange-500;
}
.fc-direction-ltr .fc-timegrid-slot-label-frame {
@apply px-3 py-1.5 text-left text-sm font-medium text-gray-500 dark:text-gray-400;
}
.fc .fc-timegrid-axis-cushion {
@apply text-sm font-medium text-gray-500 dark:text-gray-400;
}
.custom-calendar .fc-h-event {
background-color: transparent;
border: none;
color: black;
}
.fc.fc-media-screen {
@apply min-h-screen;
}
.input-date-icon::-webkit-inner-spin-button,
.input-date-icon::-webkit-calendar-picker-indicator {
opacity: 0;
-webkit-appearance: none;
}
.stocks-slider-outer .swiper-button-next:after,
.stocks-slider-outer .swiper-button-prev:after {
@apply hidden;
}
.stocks-slider-outer .swiper-button-next,
.stocks-slider-outer .swiper-button-prev {
@apply !static mt-0 h-8 w-9 rounded-full border dark:hover:bg-white/[0.05] border-gray-200 !text-gray-700 transition hover:bg-gray-100 dark:border-white/[0.03] dark:bg-gray-800 dark:!text-gray-400 dark:hover:!text-white/90;
}
.stocks-slider-outer .swiper-button-next.swiper-button-disabled,
.stocks-slider-outer .swiper-button-prev.swiper-button-disabled {
@apply bg-white opacity-50 dark:bg-gray-900;
}
.stocks-slider-outer .swiper-button-next svg,
.stocks-slider-outer .swiper-button-prev svg {
@apply !h-auto !w-auto;
}
.swiper-button-prev svg,
.swiper-button-next svg {
@apply !h-auto !w-auto;
}
.carouselTwo .swiper-button-next:after,
.carouselTwo .swiper-button-prev:after,
.carouselFour .swiper-button-next:after,
.carouselFour .swiper-button-prev:after {
@apply hidden;
}
.carouselTwo .swiper-button-next.swiper-button-disabled,
.carouselTwo .swiper-button-prev.swiper-button-disabled,
.carouselFour .swiper-button-next.swiper-button-disabled,
.carouselFour .swiper-button-prev.swiper-button-disabled {
@apply bg-white/60 !opacity-100;
}
.carouselTwo .swiper-button-next,
.carouselTwo .swiper-button-prev,
.carouselFour .swiper-button-next,
.carouselFour .swiper-button-prev {
@apply h-10 w-10 rounded-full border-[0.5px] border-white/10 bg-white/90 !text-gray-700 shadow-slider-navigation backdrop-blur-[10px];
}
.carouselTwo .swiper-button-prev,
.carouselFour .swiper-button-prev {
@apply !left-3 sm:!left-4;
}
.carouselTwo .swiper-button-next,
.carouselFour .swiper-button-next {
@apply !right-3 sm:!right-4;
}
.carouselThree .swiper-pagination,
.carouselFour .swiper-pagination {
@apply !bottom-3 !left-1/2 inline-flex !w-auto -translate-x-1/2 items-center gap-1.5 rounded-[40px] border-[0.5px] border-white/10 bg-white/60 px-2 py-1.5 shadow-slider-navigation backdrop-blur-[10px] sm:!bottom-5;
}
.carouselThree .swiper-pagination-bullet,
.carouselFour .swiper-pagination-bullet {
@apply !m-0 h-2.5 w-2.5 bg-white opacity-100 shadow-theme-xs duration-200 ease-in-out;
}
.carouselThree .swiper-pagination-bullet-active,
.carouselFour .swiper-pagination-bullet-active {
@apply w-6.5 rounded-xl;
}
.jvectormap-container {
@apply !bg-gray-50 dark:!bg-gray-900;
}
.jvectormap-region.jvectormap-element {
@apply !fill-gray-300 hover:!fill-brand-500 dark:!fill-gray-700 dark:hover:!fill-brand-500;
}
.jvectormap-marker.jvectormap-element {
@apply !stroke-gray-200 dark:!stroke-gray-800;
}
.jvectormap-tip {
@apply !bg-brand-500 !border-none !px-2 !py-1;
}
.jvectormap-zoomin,
.jvectormap-zoomout {
@apply !hidden;
}
.form-check-input:checked ~ span {
@apply border-[6px] border-brand-500 bg-brand-500 dark:border-brand-500;
}
.taskCheckbox:checked ~ .box span {
@apply opacity-100 bg-brand-500;
}
.taskCheckbox:checked ~ p {
@apply text-gray-400 line-through;
}
.taskCheckbox:checked ~ .box {
@apply border-brand-500 bg-brand-500 dark:border-brand-500;
}
.task {
transition: all 0.2s ease; /* Smooth transition for visual effects */
}
.task {
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1),
0 1px 2px 0 rgba(16, 24, 40, 0.06);
opacity: 0.8;
cursor: grabbing;
}
html {
scrollbar-gutter: stable;
overflow-y: scroll;
}
html {
overflow-y: scroll;
}
::-webkit-scrollbar {
width: 10px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-gray-50);
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: var(--color-gray-300);
border: 2px solid var(--color-gray-50);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-gray-400);
}
.dark ::-webkit-scrollbar-track {
background: var(--color-gray-950);
}
.dark ::-webkit-scrollbar-thumb {
background-color: var(--color-gray-700);
border: 2px solid var(--color-gray-950);
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: var(--color-gray-600);
}
.ql-editor pre.ql-syntax {
background-color: #23241f;
color: #f8f8f2;
overflow: visible;
padding: 10px;
border-radius: 5px;
}
.ql-toolbar.ql-snow {
border: none !important;
border-bottom: 1px solid #e5e7eb !important;
background-color: #f9fafb;
padding: 12px !important;
}
.ql-container.ql-snow {
border: none !important;
font-size: 16px;
font-family: 'Inter', sans-serif;
}
.ql-editor {
min-height: 400px;
line-height: 1.6;
}
.dark .ql-toolbar.ql-snow {
background-color: #1f2937;
border-bottom: 1px solid #374151 !important;
color: #f3f4f6;
}
.dark .ql-snow .ql-stroke {
stroke: #d1d5db;
}
.dark .ql-editor {
color: #f3f4f6;
}
+29
View File
@@ -0,0 +1,29 @@
import { Inter } from 'next/font/google';
import './globals.css';
import "flatpickr/dist/flatpickr.css";
import { SidebarProvider } from '@/context/SidebarContext';
import { ThemeProvider } from '@/context/ThemeContext';
import { Toaster } from 'sonner';
import StoreProvider from '@/store/StoreProvider';
const inter = Inter({
subsets: ["latin"],
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.className} dark:bg-gray-900`}>
<StoreProvider>
<ThemeProvider>
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
</ThemeProvider>
</StoreProvider>
</body>
</html>
);
}
+47
View File
@@ -0,0 +1,47 @@
import GridShape from "@/components/common/GridShape";
import Image from "next/image";
import Link from "next/link";
import React from "react";
export default function NotFound() {
return (
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
<GridShape />
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
ERROR
</h1>
<Image
src="/images/error/404.svg"
alt="404"
className="dark:hidden"
width={472}
height={152}
/>
<Image
src="/images/error/404-dark.svg"
alt="404"
className="hidden dark:block"
width={472}
height={152}
/>
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
We cant seem to find the page you are looking for!
</p>
<Link
href="/"
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
Back to Home Page
</Link>
</div>
{/* <!-- Footer --> */}
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
&copy; {new Date().getFullYear()} - TailAdmin
</p>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { useState, useEffect } from 'react';
import { apiGetCurrentUser } from "@/service/auth";
export default function GetUser() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const result = await apiGetCurrentUser();
// console.log("Current User from useEffect:", result);
setUser(result);
} catch (err) {
// console.error("Lỗi 401 hoặc lỗi kết nối:", err);
// setError(err);
} finally {
setLoading(false);
}
};
fetchUser();
}, []);
if (loading) return <div>Đang tải thông tin...</div>;
if (error) return <div>Bạn chưa đăng nhập (Lỗi 401)</div>;
return (
<div className="">
<h1>Thông tin người dùng hiện tại:</h1>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import BarChartOne from "@/components/charts/bar/BarChartOne";
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Bar Chart | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Bar Chart page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
};
export default function page() {
return (
<div>
<PageBreadcrumb pageTitle="Bar Chart" />
<div className="space-y-6">
<ComponentCard title="Bar Chart 1">
<BarChartOne />
</ComponentCard>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import LineChartOne from "@/components/charts/line/LineChartOne";
import ComponentCard from "@/components/common/ComponentCard";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "Next.js Line Chart | TailAdmin - Next.js Dashboard Template",
description:
"This is Next.js Line Chart page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template",
};
export default function LineChart() {
return (
<div>
<PageBreadcrumb pageTitle="Line Chart" />
<div className="space-y-6">
<ComponentCard title="Line Chart 1">
<LineChartOne />
</ComponentCard>
</div>
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { SafeHTMLRenderer } from "@/components/ui/parse/SafeHTMLRenderer";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { apiDeleteHistorianCV } from "@/service/historianService";
import Swal from "sweetalert2";
import { statusConfig } from "@/service/handler";
import Lightbox from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Captions from "yet-another-react-lightbox/plugins/captions";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css";
import Image from "next/image";
import { URL_MEDIA } from "../../../../../api";
export default function ApplicationDetailPage() {
const application = useSelector(
(state: RootState) => state.user.selectedApplication,
);
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const [errMessage, setErrMessage] = useState<string>(
"Không thể xóa đơn đăng ký này.",
);
const [index, setIndex] = useState(-1);
if (!application) {
return (
<div className="p-10 text-center text-zinc-500 font-medium">
Đang tải hoặc không dữ liệu...
</div>
);
}
const config = statusConfig[application.status] || statusConfig.PENDING;
const isImageFile = (file: any) => {
const isImageMime = file.mime_type?.startsWith("image/");
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
return isImageMime || isImageExt;
};
const imageMediaOnly = (application.media || []).filter(isImageFile);
const imageSlides = imageMediaOnly.map((item: any) => ({
src: `${URL_MEDIA}${item.storage_key}`,
title: item.original_name,
description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`,
}));
const handleMediaClick = (item: any) => {
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
if (isImageFile(item)) {
const photoIndex = imageMediaOnly.findIndex(
(img: any) => img.id === item.id,
);
setIndex(photoIndex);
} else {
const googleDocsUrl = `https://docs.google.com/viewer?url=${encodeURIComponent(fileUrl)}&embedded=true`;
window.open(googleDocsUrl, "_blank");
}
};
const handleDelete = async () => {
const isDarkMode = document.documentElement.classList.contains("dark");
const result = await Swal.fire({
title: "Xác nhận xóa?",
text: "Bạn sẽ không thể khôi phục lại đơn đăng ký này!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#ef4444",
cancelButtonColor: "#71717a",
confirmButtonText: "Xóa ngay",
cancelButtonText: "Hủy",
background: isDarkMode ? "#18181b" : "#fff",
color: isDarkMode ? "#fff" : "#000",
});
if (result.isConfirmed) {
try {
setIsDeleting(true);
await apiDeleteHistorianCV(application.id);
// Thành công (200 OK)
await Swal.fire({
title: "Đã xóa!",
icon: "success",
timer: 1500,
showConfirmButton: false,
background: isDarkMode ? "#18181b" : "#fff",
color: isDarkMode ? "#fff" : "#000",
});
router.push("/account");
} catch (error: any) {
setErrMessage(
error.response?.data?.message || "Có lỗi xảy ra khi xóa!",
);
if (
error.response?.data?.message ===
"You don't have permission to access this resource."
) {
setErrMessage("Bạn không có quyền xóa đơn đăng ký này.");
}
Swal.fire({
title: "Lỗi!",
text: errMessage,
icon: "error",
background: isDarkMode ? "#18181b" : "#fff",
color: isDarkMode ? "#fff" : "#000",
});
} finally {
setIsDeleting(false);
}
}
};
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// console.log("Application Detail:", application);
return (
<div className="max-w-5xl mx-auto p-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border dark:border-zinc-800">
<div className="flex justify-between items-center mb-8 border-b dark:border-zinc-800 pb-6">
<div>
<h2 className="text-xl font-semibold dark:text-zinc-50 tracking-tight">
Chi tiết đơn đăng
</h2>
</div>
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-md backdrop-blur-md border ${config.container}`}
>
<span className={`text-[12px] font-bold uppercase tracking-wider`}>
{application.status}
</span>
</div>
</div>
<div className="space-y-10">
<section>
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest mb-4 block">
Nội dung hiển thị (CV)
</label>
<div className="rounded-2xl border-2 border-zinc-50 dark:border-zinc-800 p-6 bg-zinc-50/50 dark:bg-zinc-950/30">
<SafeHTMLRenderer html={application.content} />
</div>
</section>
{application.media && application.media.length > 0 && (
<section>
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest mb-4 block">
Tài liệu đính kèm ({application.media.length})
</label>
<div className="flex flex-row gap-4 overflow-x-auto pb-4 scrollbar-hide">
{application.media.map((item: any) => {
const isImg = isImageFile(item);
return (
<div
key={item.id}
onClick={() => handleMediaClick(item)}
className="group relative min-w-[160px] h-[160px] overflow-hidden rounded-2xl border-2 border-zinc-100 dark:border-zinc-800 cursor-pointer bg-zinc-100 dark:bg-zinc-900 flex flex-col items-center justify-center transition-all hover:border-blue-500"
>
{isImg ? (
<>
<img
src={`${URL_MEDIA}${item.storage_key}`}
alt={item.original_name}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-3">
<p className="text-[10px] text-white font-bold truncate">
Xem nh
</p>
</div>
</>
) : (
<div className="flex flex-col items-center p-4">
<div className="w-12 h-12 mb-3 flex items-center justify-center bg-white dark:bg-zinc-800 rounded-xl shadow-inner text-2xl">
📄
</div>
<p className="text-[10px] font-black text-zinc-700 dark:text-zinc-200 uppercase tracking-tighter">
.{item.storage_key.split(".").pop()}
</p>
<p className="text-[9px] text-zinc-400 truncate w-28 mt-1 font-medium italic">
{item.original_name}
</p>
<span className="mt-3 text-[9px] bg-blue-600 text-white px-3 py-1 rounded-full font-black uppercase opacity-0 group-hover:opacity-100 transition-opacity">
Preview
</span>
</div>
)}
</div>
);
})}
</div>
</section>
)}
{application.status !== "APPROVED" &&
application.status !== "REJECTED" && (
<div className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
<div className="flex justify-end w-full">
<button
onClick={handleDelete}
disabled={isDeleting}
className="group flex items-center gap-2 px-8 py-3 bg-red-100 hover:bg-red-500 text-red-600 hover:text-white dark:bg-red-950/20 dark:hover:bg-red-600 dark:text-red-400 dark:hover:text-white text-sm font-black rounded-xl transition-all duration-300 disabled:opacity-50"
>
{isDeleting ? "ĐANG XỬ LÝ..." : "XÓA"}
</button>
</div>
</div>
)}
{application?.reviewer && (
<section className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
<label className="text-[11px] font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-3 block">
Phản hồi từ người kiểm duyệt
</label>
<div className="p-5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/50">
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
{application?.reviewer?.avatar_url ? (
<Image
src={application.reviewer.avatar_url}
alt={application.reviewer.display_name || "Avatar"}
width={36}
height={36}
className="w-9 h-9 rounded-full object-cover border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800"
/>
) : (
<div className="w-9 h-9 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 font-medium text-sm">
{application?.reviewer?.display_name?.charAt(0) || "R"}
</div>
)}
<div>
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{application?.reviewer?.display_name}
</h4>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">
{application?.reviewer?.email}
</p>
</div>
</div>
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
{formatDate(application?.reviewed_at)}
</span>
</div>
<div className="relative ml-4 pl-4 border-l border-zinc-300 dark:border-zinc-700">
<div className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed">
{application?.review_note ? (
<p>{application.review_note}</p>
) : (
<p className="italic text-zinc-500">Không ghi chú bổ sung.</p>
)}
</div>
</div>
</div>
</section>
)}
</div>
<Lightbox
index={index}
open={index >= 0}
close={() => setIndex(-1)}
slides={imageSlides}
plugins={[Zoom, Captions]}
styles={{
root: {
zIndex: 99999,
"--yarl__color_backdrop": "rgba(0, 0, 0, 0.95)",
},
}}
/>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import AccountDetails from "@/components/user-profile/AccountDetails";
import UserInfoCard from "@/components/user-profile/UserInfoCard";
import UserMetaCard from "@/components/user-profile/UserMetaCard";
import { UserMetaCardProps } from "@/interface/user";
import { apiGetCurrentUser } from "@/service/auth";
import { setUserData } from "@/store/features/userSlice";
import { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
export default function Profile() {
const [user, setUser] = useState<UserMetaCardProps | null>(null);
const [loading, setLoading] = useState(true);
const dispatch = useDispatch();
useEffect(() => {
const fetchUser = async () => {
try {
const userData = await apiGetCurrentUser();
dispatch(setUserData(userData.data));
setUser(userData);
} catch (err) {
console.error("Lỗi:", err);
} finally {
setLoading(false);
}
};
fetchUser();
}, []);
return (
<div>
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
Profile
</h3>
<div className="space-y-6">
<UserMetaCard data={user ?? {}} />
<UserInfoCard data={{ ...user, openEdit: true }} />
<AccountDetails data={user ?? {}} />
</div>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useSidebar } from "@/context/SidebarContext";
import AppHeader from "@/layout/AppHeader";
import AppSidebar from "@/layout/AppSidebar";
import Backdrop from "@/layout/Backdrop";
import { apiGetCurrentUser } from "@/service/auth";
import { setUserData } from "@/store/features/userSlice";
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
const dispatch = useDispatch()
useEffect(() => {
const fetchUser = async () => {
try {
const userData = await apiGetCurrentUser();
dispatch(setUserData(userData.data));
} catch (err) {
console.error("Lỗi:", err);
}
};
fetchUser();
}, [])
// Dynamic class for main content margin based on sidebar state
const mainContentMargin = isMobileOpen
? "ml-0"
: isExpanded || isHovered
? "lg:ml-[290px]"
: "lg:ml-[90px]";
return (
<div className="min-h-screen xl:flex">
{/* Sidebar and Backdrop */}
<AppSidebar />
<Backdrop />
{/* Main Content Area */}
<div
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
>
{/* Header */}
<AppHeader />
{/* Page Content */}
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import ApplicationLibrary from "@/components/user-profile/ApplicationList";
import { MediaDto } from "@/interface/media";
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
import MediaLibrary from "@/components/user-profile/Media";
export default function LibraryPage() {
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
const [applications, setApplications] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchLibraryContent = async () => {
try {
const [mediaResponse, userApplications] = await Promise.all([
apiGetCurrentUserMedia(),
apiGetCurrentUserApplications()
]);
if (userApplications?.data) setApplications(userApplications.data);
setMediaData(mediaResponse);
} catch (err) {
console.error("Lỗi khi tải thư viện:", err);
} finally {
setLoading(false);
}
};
fetchLibraryContent();
}, []);
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
</div>
);
}
const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0;
return (
<div className="min-h-screen bg-gray-50/50 p-4 dark:bg-zinc-950 lg:p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
Thư viện
</h1>
</div>
{hasNoData ? (
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 py-24 text-center dark:border-zinc-800">
<p className="text-lg font-medium text-gray-500 dark:text-gray-400">
Chưa nội dung nào trong thư viện
</p>
</div>
) : (
<div className="space-y-12">
{(mediaData?.data?.length ?? 0) > 0 && (
<section>
<MediaLibrary data={mediaData ?? {}} />
</section>
)}
{applications.length > 0 && (
<section>
<ApplicationLibrary applications={applications} />
</section>
)}
</div>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
import React from "react";
import MonthlyTarget from "@/components/ecommerce/MonthlyTarget";
import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart";
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
import RecentOrders from "@/components/ecommerce/RecentOrders";
import DemographicCard from "@/components/ecommerce/DemographicCard";
export const metadata: Metadata = {
title:
"Admin Dashboard",
description: "This is Dashboard Home for History Web",
};
export default function Ecommerce() {
return (
<div className="grid grid-cols-12 gap-4 md:gap-6">
<div className="col-span-12 space-y-6 xl:col-span-7">
<EcommerceMetrics />
<MonthlySalesChart />
</div>
<div className="col-span-12 xl:col-span-5">
<MonthlyTarget />
</div>
<div className="col-span-12">
<StatisticsChart />
</div>
<div className="col-span-12 xl:col-span-5">
<DemographicCard />
</div>
<div className="col-span-12 xl:col-span-7">
<RecentOrders />
</div>
</div>
);
}
+362
View File
@@ -0,0 +1,362 @@
"use client";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import RichTextEditor from "@/components/form/role-upgrade/Editor";
import {
confirmUpload,
getPresignedUrl,
uploadFileToS3,
} from "@/service/mediaService";
import React, { useRef, useState } from "react";
import Image from "next/image";
import Lightbox from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Captions from "yet-another-react-lightbox/plugins/captions";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css";
import { createHistorianCV } from "@/service/historianService";
import { toast } from "sonner";
import Swal from "sweetalert2";
type PendingFile = {
id: string;
file: File;
previewUrl: string;
name: string;
size: number;
type: "image" | "document";
extension: string;
presigned?: any;
};
export default function RoleUpgrade() {
const [content, setContent] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [verifyType, setVerifyType] = useState<string>("OTHER");
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [lightboxIndex, setLightboxIndex] = useState(-1);
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const handleIframeLoad = () => {
const iframe = iframeRef.current;
if (!iframe || !iframe.contentDocument) return;
const updateHeight = () => {
if (iframe.contentDocument) {
iframe.style.height = "auto";
const scrollHeight =
iframe.contentDocument.documentElement.scrollHeight;
iframe.style.height = `${scrollHeight}px`;
}
};
updateHeight();
const resizeObserver = new ResizeObserver(() => {
updateHeight();
});
if (iframe.contentDocument.body) {
resizeObserver.observe(iframe.contentDocument.body);
}
};
const cleanHTMLContent = (rawHtml: string) => {
if (!rawHtml) return "";
const doc = new DOMParser().parseFromString(rawHtml, "text/html");
let decoded = doc.documentElement.textContent || rawHtml;
decoded = decoded.replace(/<pre[^>]*>/g, "").replace(/<\/pre>/g, "");
return decoded;
};
const handleContentChange = (value: string) => {
setContent(value);
};
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = event.target.files;
if (!files || files.length === 0) return;
setIsPreparingFiles(true);
try {
const newFilesPromises = Array.from(files).map(async (file) => {
const isImage = file.type.startsWith("image/");
const extension = file.name.split(".").pop()?.toLowerCase() || "";
const presigned = await getPresignedUrl(file);
return {
id: Math.random().toString(36).substring(7),
file: file,
previewUrl: isImage ? URL.createObjectURL(file) : "",
name: file.name,
size: file.size,
type: isImage ? "image" : "document",
extension: extension,
presigned: presigned,
} as PendingFile;
});
const newPendingFiles = await Promise.all(newFilesPromises);
setPendingFiles((prev) => [...prev, ...newPendingFiles]);
} catch (error) {
console.error("Lỗi khi chuẩn bị file:", error);
toast.error("Lỗi khi chuẩn bị kết nối upload.");
} finally {
setIsPreparingFiles(false);
if (event.target) event.target.value = "";
}
};
const removePendingFile = (idToRemove: string, e: React.MouseEvent) => {
e.stopPropagation();
setPendingFiles((prev) => prev.filter((item) => item.id !== idToRemove));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) {
toast.warning("Vui lòng nhập nội dung hồ sơ!");
return;
}
try {
setIsSubmitting(true);
const uploadPromises = pendingFiles.map(async (item) => {
await uploadFileToS3(item.file, item.presigned);
const confirmRes = await confirmUpload(item.presigned.token_id);
return confirmRes?.data?.id || confirmRes?.id;
});
const uploadedMediaIds = await Promise.all(uploadPromises);
const cleanPayloadContent = cleanHTMLContent(content);
const payload = {
content: cleanPayloadContent,
media_ids: uploadedMediaIds,
verify_type: verifyType,
};
await createHistorianCV(payload);
Swal.fire({
title: "Gửi yêu cầu thành công!",
text: "Hồ sơ của bạn đã được ghi nhận và đang chờ duyệt.",
icon: "success",
});
setContent("");
setPendingFiles([]);
setVerifyType("OTHER");
} catch (error) {
console.error("Lỗi submit:", error);
toast.error("Có lỗi xảy ra khi gửi yêu cầu.");
} finally {
setIsSubmitting(false);
}
};
const imageFiles = pendingFiles.filter((f) => f.type === "image");
const slides = imageFiles.map((item) => ({
src: item.previewUrl,
title: item.name,
description: `Size: ${(item.size / 1024).toFixed(2)} KB`,
}));
const handleItemClick = (item: PendingFile) => {
if (item.type === "image") {
const index = imageFiles.findIndex((img) => img.id === item.id);
setLightboxIndex(index);
} else {
const fileUrl = URL.createObjectURL(item.file);
window.open(fileUrl, "_blank");
}
};
const getDocumentStyle = (ext: string) => {
if (["pdf"].includes(ext))
return { color: "text-red-500", bg: "bg-red-50" };
if (["doc", "docx"].includes(ext))
return { color: "text-blue-500", bg: "bg-blue-50" };
if (["xls", "xlsx"].includes(ext))
return { color: "text-emerald-500", bg: "bg-emerald-50" };
return { color: "text-gray-500", bg: "bg-gray-100" };
};
return (
<div className="max-w-4xl mx-auto pb-20">
<PageBreadcrumb pageTitle="Đăng ký trở thành Nhà sử học" />
<div className="flex items-center justify-between bg-white dark:bg-gray-900 p-2 rounded-xl border border-gray-200 dark:border-gray-800 mb-6">
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowPreview(false)}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all ${!showPreview ? "bg-blue-600 text-white" : "text-gray-500 hover:bg-gray-100"}`}
>
Soạn thảo
</button>
<button
type="button"
onClick={() => setShowPreview(true)}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all ${showPreview ? "bg-blue-600 text-white" : "text-gray-500 hover:bg-gray-100"}`}
>
Xem trước giao diện
</button>
</div>
<span className="text-[10px] font-black uppercase text-gray-400 mr-2 tracking-widest">
{showPreview ? "Preview Mode" : "Edit Mode"}
</span>
</div>
<div className="flex flex-col gap-6">
<div className="relative min-h-[400px]">
{!showPreview ? (
<RichTextEditor value={content} onChange={handleContentChange} />
) : (
<div className="bg-white border border-zinc-200 rounded-xl shadow-inner overflow-hidden min-h-[400px]">
{content ? (
<iframe
ref={iframeRef}
onLoad={handleIframeLoad}
srcDoc={`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: system-ui, sans-serif;
padding: 20px;
margin: 0;
color: black;
overflow-y: hidden;
}
img { max-width: 100%; height: auto; display: block; margin-top: 10px; }
</style>
</head>
<body>
${cleanHTMLContent(content)}
</body>
</html>
`}
title="Preview"
className="w-full border-none flex-1 transition-all duration-300"
sandbox="allow-same-origin"
/>
) : (
<div className="flex flex-col items-center justify-center h-[400px] text-zinc-400 italic bg-gray-50 dark:bg-gray-900">
<p>Chưa nội dung đ xem trước</p>
</div>
)}
</div>
)}
</div>
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-gray-900">
<label className="block mb-3 text-sm font-semibold text-gray-700 dark:text-white">
Loại hồ xác minh <span className="text-red-500">*</span>
</label>
<select
value={verifyType}
onChange={(e) => setVerifyType(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 dark:bg-gray-800 dark:border-gray-700"
>
<option value="OTHER">Tài liệu khác (Other)</option>
<option value="ID_CARD">Thẻ nhận dạng (ID Card)</option>
<option value="EDUCATION">Bằng cấp giáo dục (Education)</option>
<option value="EXPERT">Chứng nhận chuyên gia (Expert)</option>
</select>
</div>
{/* Upload Files */}
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Tài liệu đính kèm</h3>
<label className="cursor-pointer px-4 py-2 rounded-lg border border-dashed border-blue-500 text-blue-500 hover:bg-blue-50 transition">
<input
type="file"
multiple
className="hidden"
accept="image/*,.pdf,.doc,.docx"
onChange={handleFileChange}
/>
<span>+ Thêm tệp</span>
</label>
</div>
{pendingFiles.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-4">
{pendingFiles.map((item) => {
const docStyle = getDocumentStyle(item.extension);
return (
<div
key={item.id}
className="relative group aspect-square border rounded-xl overflow-hidden bg-gray-50"
>
{item.type === "image" ? (
<Image
src={item.previewUrl}
alt="preview"
fill
className="object-cover"
/>
) : (
<div
className={`flex flex-col items-center justify-center h-full ${docStyle.bg} p-2`}
>
<span className={`text-xs font-bold ${docStyle.color}`}>
{item.extension.toUpperCase()}
</span>
<span className="text-[10px] truncate w-full text-center mt-1">
{item.name}
</span>
</div>
)}
<button
onClick={(e) => removePendingFile(item.id, e)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition"
>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
})}
</div>
)}
</div>
<button
onClick={handleSubmit}
disabled={isSubmitting || isPreparingFiles}
className={`w-full py-4 text-white font-bold rounded-2xl shadow-lg transition-all ${isSubmitting || isPreparingFiles ? "bg-gray-400" : "bg-blue-600 hover:bg-blue-700"}`}
>
{isSubmitting ? "Đang xử lý..." : "Gửi yêu cầu nâng cấp"}
</button>
</div>
<Lightbox
index={lightboxIndex}
open={lightboxIndex >= 0}
close={() => setLightboxIndex(-1)}
slides={slides}
plugins={[Zoom, Captions]}
/>
</div>
);
}