Commit 62af7d47 authored by Lê Bảo Hồng Đức's avatar Lê Bảo Hồng Đức

Merge branch 'feature/siteinfo' into 'develop-news'

feat: implement header, admin views, sidebar, and core api query configurations

See merge request !66
parents 1334e92b baedc93a
// Core
import { AxiosError, isAxiosError } from 'axios'
import { QueryClient } from '@tanstack/react-query'
// App
// import router from '@/router'
import { handleAdminUnauthorized } from '@/lib/auth/admin-auth'
// import useProfileStore from '@stores/profile'
import { QueryData } from '@/lib/types/base-api'
import { AxiosError, isAxiosError } from 'axios'
import { QueryClient } from '@tanstack/react-query'
// App
// import router from '@/router'
import { handleAdminUnauthorized } from '@/lib/auth/admin-auth'
// import useProfileStore from '@stores/profile'
import { QueryData } from '@/lib/types/base-api'
// import { BASE_PATHS } from '@/constants/path'
// Constants
const RETRY_COUNT = 3
const EXPIRED_TOKEN_ERROR = 401
const DENIED_PERMISSION_ERROR = 403
const INTERNAL_SERVER_ERROR = 500
const API_QUERY_STALE_TIME = 2 * 60 * 1000
const API_QUERY_GC_TIME = 10 * 60 * 1000
const RETRY_COUNT = 3
const EXPIRED_TOKEN_ERROR = 401
const DENIED_PERMISSION_ERROR = 403
const INTERNAL_SERVER_ERROR = 500
const API_QUERY_STALE_TIME = 2 * 60 * 1000
const API_QUERY_GC_TIME = 10 * 60 * 1000
// Utils
// Handle check base retry logical
......@@ -27,8 +27,10 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
// Expired token error
if (error.response?.status === EXPIRED_TOKEN_ERROR) {
handleUnAuthorizationError()
return false
if (typeof window !== "undefined" && window.location.pathname.startsWith("/admin")) {
handleUnAuthorizationError();
}
return false;
}
// Denied permission error
......@@ -42,15 +44,15 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
}
// Handle un authorization error
const handleUnAuthorizationError = () => {
void handleAdminUnauthorized()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// })
// router.navigate('')
}
const handleUnAuthorizationError = () => {
void handleAdminUnauthorized()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// })
// router.navigate('')
}
// Handle delay value
const handleDelayRetry = (failureCount: number) => failureCount * 1000 + Math.random() * 1000
......@@ -58,13 +60,13 @@ const handleDelayRetry = (failureCount: number) => failureCount * 1000 + Math.ra
// Query client
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: API_QUERY_STALE_TIME,
gcTime: API_QUERY_GC_TIME,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
placeholderData: (previousData: unknown) => previousData,
queries: {
staleTime: API_QUERY_STALE_TIME,
gcTime: API_QUERY_GC_TIME,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
placeholderData: (previousData: unknown) => previousData,
retry(failureCount, error) {
if (!handleCheckBaseRetryLogical(failureCount, error)) return false
......
'use client';
"use client";
import ImageNext from "@/components/shared/image-next";
import { Swiper, SwiperSlide } from "swiper/react";
......@@ -7,40 +7,98 @@ import { Swiper as SwiperType } from "swiper/types";
import { useRef } from "react";
import "swiper/css";
import { useGetBanner } from "@/api/endpoints/banner";
import { useQuery } from "@tanstack/react-query";
import { fetchCmsFileById, resolveCmsFileUrl } from "@/lib/api/files";
import { Skeleton } from "@/components/ui/skeleton";
type ApiEnvelope<T> = {
responseData?: T;
data?: {
responseData?: T;
};
};
const getEnvelopeData = <T,>(payload?: ApiEnvelope<T>) =>
payload?.responseData ?? payload?.data?.responseData;
function BannerSlideItem({ fileId, alt }: { fileId: string; alt: string }) {
const { data: file, isPending } = useQuery({
queryKey: ["file", fileId],
queryFn: () => fetchCmsFileById(fileId),
enabled: !!fileId,
});
if (isPending) {
return (
<Skeleton className="w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px]" />
);
}
const url = file ? resolveCmsFileUrl(file.path) : "/img-error.png";
return (
<ImageNext
src={url}
alt={alt}
width={2560}
height={720}
sizes="100vw"
className="w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
/>
);
}
const Banner = () => {
const swiperRef = useRef<SwiperType | null>(null);
const { data: bannerData } = useGetBanner({
filters: "status@=ACTIVE",
sortField: "display_order",
sortOrder: "asc",
});
const pageData = getEnvelopeData<{ rows?: any[] }>(bannerData);
const rows = pageData?.rows ?? [];
if (!rows || rows.length === 0) {
return (
<div className="w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] bg-slate-100 flex items-center justify-center">
<Skeleton className="w-full h-full" />
</div>
);
}
return (
<Swiper
modules={[Autoplay]}
autoplay={{ delay: 4000, disableOnInteraction: false }}
loop
loop={rows.length > 1}
slidesPerView={1}
onSwiper={(s) => (swiperRef.current = s)}
className="w-full overflow-hidden"
>
<SwiperSlide>
<ImageNext
src="https://vcci-hcm.org.vn/wp-content/uploads/2025/10/1.1.-Hero-Banner-CEO-2025-Bi-Sai-Nam-2025-Nhe-2560x720-Px.jpg.webp"
alt="Banner"
width={2560}
height={720}
sizes="100vw"
className="w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
/>
</SwiperSlide>
<SwiperSlide>
<ImageNext
src="https://vcci-hcm.org.vn/wp-content/uploads/2022/07/Landscape-HCM_3-01.png"
alt="Banner"
width={2560}
height={720}
sizes="100vw"
className="w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
/>
</SwiperSlide>
{rows.map((row: any) => (
<SwiperSlide key={row.id}>
{row.file_id ? (
<BannerSlideItem
fileId={row.file_id}
alt={row.banner_name || "Banner"}
/>
) : (
<ImageNext
src="/img-error.png"
alt={row.banner_name || "Banner"}
width={2560}
height={720}
sizes="100vw"
className="w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
/>
)}
</SwiperSlide>
))}
</Swiper>
);
}
};
export default Banner;
......@@ -13,18 +13,61 @@ import {
Youtube,
} from "lucide-react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { subscribeNewsletterEmail } from "@/lib/api/newsletter-subscriptions";
import { getSiteInformation } from "@/api/endpoints/site-information";
import type { SiteInformationData } from "@/api/models";
const socialLinks = [
{ icon: <Facebook className="h-5 w-5" />, link: "https://www.facebook.com/VCCIHCMC/" },
{ icon: <Twitter className="h-5 w-5" />, link: "https://twitter.com/VCCI_HCM" },
{ icon: <Youtube className="h-5 w-5" />, link: "https://www.youtube.com/user/VCCIHCMC" },
type ApiEnvelope<T> = {
responseData?: T;
data?: {
responseData?: T;
};
};
type SocialItem = {
key: string;
url: string;
icon: React.ReactNode;
};
const fallbackSocials: SocialItem[] = [
{
key: "facebook",
url: "https://www.facebook.com/VCCIHCMC/",
icon: <Facebook className="h-5 w-5" />,
},
{
key: "twitter",
url: "https://twitter.com/VCCI_HCM",
icon: <Twitter className="h-5 w-5" />,
},
{
key: "youtube",
url: "https://www.youtube.com/user/VCCIHCMC",
icon: <Youtube className="h-5 w-5" />,
},
{
key: "linkedin",
url: "https://www.linkedin.com/company/vietnam-chamber-of-commerce-and-industry-ho-chi-minh-city-branch-vcci-hcm-?trk=biz-companies-cym",
icon: <Linkedin className="h-5 w-5" />,
link: "https://www.linkedin.com/company/vietnam-chamber-of-commerce-and-industry-ho-chi-minh-city-branch-vcci-hcm-?trk=biz-companies-cym",
},
];
const getEnvelopeData = <T,>(payload?: ApiEnvelope<T> | null) =>
payload?.responseData ?? payload?.data?.responseData;
const getSocialIcon = (key: string): React.ReactNode => {
const normalized = key.toLowerCase();
if (normalized.includes("facebook")) return <Facebook className="h-5 w-5" />;
if (normalized.includes("twitter") || normalized === "x") {
return <Twitter className="h-5 w-5" />;
}
if (normalized.includes("youtube")) return <Youtube className="h-5 w-5" />;
if (normalized.includes("linkedin")) return <Linkedin className="h-5 w-5" />;
return null;
};
const quickLinks = [
{ label: "Giới thiệu", href: "/gioi-thieu" },
{ label: "Hội viên", href: "/danh-ba-hoi-vien" },
......@@ -32,7 +75,8 @@ const quickLinks = [
{ label: "Xúc tiến Thương mại", href: "/xuc-tien-thuong-mai/co-hoi/" },
];
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
function Footer() {
const [email, setEmail] = useState("");
......@@ -42,6 +86,68 @@ function Footer() {
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
const { data: siteInformationResponse } =
useQuery<ApiEnvelope<SiteInformationData> | null>({
queryKey: ["site-information"],
queryFn: () => getSiteInformation().catch(() => null),
staleTime: 5 * 60 * 1000,
});
const siteInformation = getEnvelopeData<SiteInformationData>(
siteInformationResponse,
);
const primaryBranch =
siteInformation?.branches?.find((branch) => branch?.is_active) ??
siteInformation?.branches?.[0] ??
null;
const branches = (siteInformation?.branches ?? [])
.filter((branch) => branch?.is_active ?? true)
.sort((left, right) => (left.sort_order ?? 0) - (right.sort_order ?? 0));
const extraBranches = branches.filter(
(branch) => branch?.id && branch?.id !== primaryBranch?.id,
);
const contactInfo = {
name:
primaryBranch?.branch_name ??
siteInformation?.website_name ??
"LIÊN ĐOÀN THƯƠNG MẠI & CÔNG NGHIỆP VIỆT NAM - CHI NHÁNH KHU VỰC THÀNH PHỐ HỒ CHÍ MINH",
address:
siteInformation?.address ??
primaryBranch?.address ??
"171 Võ Thị Sáu, Phường Xuân Hòa, TP. HCM",
telephone:
siteInformation?.telephone ??
primaryBranch?.telephone ??
primaryBranch?.hotline ??
"+84 28 3932 6598",
fax: primaryBranch?.fax ?? "+84 28 3932 5472",
email:
siteInformation?.email ?? primaryBranch?.email ?? "info@vcci-hcm.org.vn",
};
const socialLinks = (() => {
const socials =
siteInformation?.socials ?? siteInformation?.link_socials ?? [];
const active = socials
.filter((item) => item?.is_active && item?.url)
.sort((left, right) => (left.sort_order ?? 0) - (right.sort_order ?? 0))
.map((item) => {
const key = item.icon_key || item.platform || item.label || "";
const icon = getSocialIcon(key);
if (!icon || !item.url) return null;
return {
key: item.id || key,
url: item.url,
icon,
} as SocialItem;
})
.filter((item): item is SocialItem => item !== null);
return active.length ? active : fallbackSocials;
})();
const handleSubmit = async () => {
const trimmedEmail = email.trim();
let hasError = false;
......@@ -75,7 +181,11 @@ function Footer() {
setAccepted(false);
setMessage("Đăng ký nhận thông tin thành công.");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Không thể đăng ký nhận thông tin.");
setMessage(
error instanceof Error
? error.message
: "Không thể đăng ký nhận thông tin.",
);
} finally {
setSubmitting(false);
}
......@@ -144,48 +254,65 @@ function Footer() {
</div>
<div>
<h2 className="client-footer-title uppercase">
Liên hệ
</h2>
<h2 className="client-footer-title uppercase">Liên hệ</h2>
<div className="mt-2.5 h-[4px] w-[48px] rounded-full bg-[#f7b500]" />
<div className="mt-5 space-y-4">
<p className="max-w-[420px] text-[16px] font-semibold leading-[1.5] text-[#dce7ff]">
LIÊN ĐOÀN THƯƠNG MẠI & CÔNG NGHIỆP VIỆT NAM - CHI NHÁNH KHU VỰC THÀNH PHỐ HỒ CHÍ MINH
{contactInfo.name}
</p>
<div className="space-y-2.5 text-[15px] text-[#c7d8ff]">
<div className="flex items-start gap-3">
<MapPin className="mt-0.5 h-4 w-4 shrink-0 text-[#f7b500]" />
<span>171 Võ Thị Sáu, Phường Xuân Hòa, TP. HCM</span>
<span>{contactInfo.address}</span>
</div>
<div className="flex items-center gap-3">
<Phone className="h-4 w-4 shrink-0 text-[#f7b500]" />
<span>+84 28 3932 6598</span>
<span>{contactInfo.telephone}</span>
</div>
<div className="flex items-center gap-3">
<Printer className="h-4 w-4 shrink-0 text-[#f7b500]" />
<span>+84 28 3932 5472</span>
<span>{contactInfo.fax}</span>
</div>
<div className="flex items-center gap-3">
<Mail className="h-4 w-4 shrink-0 text-[#f7b500]" />
<a href="mailto:info@vcci-hcm.org.vn">info@vcci-hcm.org.vn</a>
<a href={`mailto:${contactInfo.email}`}>
{contactInfo.email}
</a>
</div>
</div>
{extraBranches.length > 0 ? (
<div className="pt-4">
<p className="text-[14px] font-semibold uppercase text-[#dce7ff]">
Các chi nhánh khác
</p>
<div className="mt-3 space-y-3 text-[14px] text-[#c7d8ff]">
{extraBranches.map((branch) => (
<div key={branch.id} className="space-y-1">
{branch.branch_name ? (
<p className="font-semibold text-[#dce7ff]">
{branch.branch_name}
</p>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>
</div>
<div>
<h2 className="client-footer-title uppercase">
Kết nối
</h2>
<h2 className="client-footer-title uppercase">Kết nối</h2>
<div className="mt-2.5 h-[4px] w-[48px] rounded-full bg-[#f7b500]" />
<div className="mt-5 flex flex-wrap gap-3">
{socialLinks.map((item) => (
<a
key={item.link}
href={item.link}
key={item.key}
href={item.url}
target="_blank"
rel="noreferrer"
className="flex h-11 w-11 items-center justify-center rounded-full bg-[#2a4ec4] text-white transition-colors hover:bg-[#3b60da]"
......
......@@ -7,8 +7,16 @@ import { useQuery } from "@tanstack/react-query";
import Image from "next/image";
import Link from "next/link";
import logo from "@/assets/VCCI-HCM-logo-VN-2025.png";
import { useGetLogo } from "@/api/endpoints/logo";
import { getSiteInformation } from "@/api/endpoints/site-information";
import { resolveUploadUrl } from "@/links";
import type { Logo } from "@/api/models/logo";
import type {
SiteInformationData,
SiteInformationSocialLink,
} from "@/api/models";
import MenuItem from "@/components/base/menu-item";
import { useCustomClient } from "@/api/mutator/custom-client";
import { useCustomClient as customClient } from "@/api/mutator/custom-client";
import type { Category } from "@/api/models/category";
import { getCategoryFallbackResponse } from "@/mockdata/categories";
......@@ -26,6 +34,67 @@ type CategoryListResponse = {
};
};
type LogoListEnvelope = {
data?: {
responseData?: {
rows?: Logo[];
};
};
};
type ApiEnvelope<T> = {
responseData?: T;
data?: {
responseData?: T;
};
};
type SocialItem = {
key: string;
url: string;
icon: React.ReactNode;
};
const getEnvelopeData = <T,>(payload?: ApiEnvelope<T> | null) =>
payload?.responseData ?? payload?.data?.responseData;
const fallbackSocials: SocialItem[] = [
{
key: "facebook",
url: "https://www.facebook.com/VCCIHCMC/",
icon: <Facebook size={12} fill="currentColor" />,
},
{
key: "twitter",
url: "https://twitter.com/VCCI_HCM",
icon: <Twitter size={12} fill="currentColor" />,
},
{
key: "youtube",
url: "https://www.youtube.com/user/VCCIHCMC",
icon: <Youtube size={12} fill="currentColor" />,
},
{
key: "linkedin",
url: "https://www.linkedin.com/company/vietnam-chamber-of-commerce-and-industry-ho-chi-minh-city-branch-vcci-hcm-?trk=biz-companies-cym",
icon: <Linkedin size={12} fill="currentColor" />,
},
];
const getSocialIcon = (key: string) => {
const normalized = key.toLowerCase();
if (normalized.includes("facebook"))
return <Facebook size={12} fill="currentColor" />;
if (normalized.includes("twitter") || normalized === "x") {
return <Twitter size={12} fill="currentColor" />;
}
if (normalized.includes("youtube"))
return <Youtube size={12} fill="currentColor" />;
if (normalized.includes("linkedin"))
return <Linkedin size={12} fill="currentColor" />;
return null;
};
function normalizeCategoryUrl(url?: string | null) {
if (!url) return "#";
return url.startsWith("/") ? url : `/${url}`;
......@@ -102,16 +171,66 @@ function Header() {
const { data: categoriesResponse } = useQuery({
queryKey: ["header-categories"],
queryFn: () =>
useCustomClient<CategoryListResponse>(
customClient<CategoryListResponse>(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC",
).catch(() => getCategoryFallbackResponse()),
staleTime: 5 * 60 * 1000,
});
const { data: currentLogo = null } = useGetLogo(
{
page: 1,
pageSize: 1,
sortField: "updated_at",
sortOrder: "desc",
},
{
query: {
select: (response: any) => {
const responseData = response?.responseData ?? response?.data?.responseData;
return (responseData?.rows?.[0] as Logo | undefined) ?? null;
},
staleTime: 5 * 60 * 1000,
},
}
);
const { data: siteInformationResponse } =
useQuery<ApiEnvelope<SiteInformationData> | null>({
queryKey: ["site-information"],
queryFn: () => getSiteInformation().catch(() => null),
staleTime: 5 * 60 * 1000,
});
const menuItems = useMemo(
() => buildHeaderMenuTree(categoriesResponse?.responseData?.rows),
[categoriesResponse?.responseData?.rows],
);
const siteInformation = getEnvelopeData<SiteInformationData>(
siteInformationResponse,
);
const socialLinks = useMemo(() => {
const socials =
siteInformation?.socials ?? siteInformation?.link_socials ?? [];
const active = socials
.filter((item) => item?.is_active && item?.url)
.sort((left, right) => (left.sort_order ?? 0) - (right.sort_order ?? 0))
.map((item): SocialItem | null => {
const key = item.icon_key || item.platform || item.label || "";
const icon = getSocialIcon(key);
if (!icon || !item.url) return null;
return {
key: item.id || key,
url: item.url,
icon,
};
})
.filter((item): item is SocialItem => Boolean(item));
return active.length ? active : fallbackSocials;
}, [siteInformation?.socials, siteInformation?.link_socials]);
useEffect(() => {
const handleScroll = () => {
......@@ -137,9 +256,8 @@ function Header() {
return (
<header className="sticky top-0 z-50 shadow-[0_1px_0_rgba(15,23,42,0.05)]">
<div
className={`hidden w-full items-center justify-center overflow-hidden bg-[#25439a] ${
isTopBarHidden ? "lg:hidden" : "h-10 lg:flex"
}`}
className={`hidden w-full items-center justify-center overflow-hidden bg-[#25439a] ${isTopBarHidden ? "lg:hidden" : "h-10 lg:flex"
}`}
>
<div className="mx-auto flex h-full w-full max-w-[1460px] items-center justify-between gap-6 px-6 xl:px-8">
<div className="flex items-center gap-2">
......@@ -167,12 +285,13 @@ function Header() {
<div className="flex items-center gap-4">
<input
className="h-[28px] w-[176px] rounded-[4px] border border-[#3a57b4] bg-[#3554b7] px-3 text-[13px] text-white outline-none placeholder:text-[13px] placeholder:text-[#b5c4ff]"
className="h-7 w-44 rounded-[4px] border border-[#3a57b4] bg-[#3554b7] px-3 text-[13px] text-white outline-none placeholder:text-[13px] placeholder:text-[#b5c4ff]"
type="text"
placeholder={"T\u00ecm ki\u1ebfm"}
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = (e.currentTarget as HTMLInputElement).value || "";
const value =
(e.currentTarget as HTMLInputElement).value || "";
const encoded = encodeURIComponent(value);
router.push(`/search?q=${encoded}&page=1`);
}
......@@ -180,50 +299,34 @@ function Header() {
/>
<div className="flex items-center gap-2">
<a
href="https://www.facebook.com/VCCIHCMC/"
target="_blank"
rel="noreferrer"
className="flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
<Facebook size={12} fill="currentColor" />
</a>
<a
href="https://twitter.com/VCCI_HCM"
target="_blank"
rel="noreferrer"
className="flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
<Twitter size={12} fill="currentColor" />
</a>
<a
href="https://www.youtube.com/user/VCCIHCMC"
target="_blank"
rel="noreferrer"
className="flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
<Youtube size={12} fill="currentColor" />
</a>
<a
href="https://www.linkedin.com/company/vietnam-chamber-of-commerce-and-industry-ho-chi-minh-city-branch-vcci-hcm-?trk=biz-companies-cym"
target="_blank"
rel="noreferrer"
className="flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
<Linkedin size={12} fill="currentColor" />
</a>
{socialLinks.map((item) => (
<a
key={item.key}
href={item.url}
target="_blank"
rel="noreferrer"
className="flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
{item.icon}
</a>
))}
</div>
</div>
</div>
</div>
<div className="border-b border-slate-200 bg-white">
<div className="mx-auto flex h-[80px] w-full max-w-[1460px] items-center justify-between gap-10 px-6 xl:px-8">
<Link href="/" className="flex w-[136px] shrink-0 items-center xl:w-[152px]">
<div className="mx-auto flex h-20 w-full max-w-[1460px] items-center justify-between gap-10 px-6 xl:px-8">
<Link
href="/"
className="flex w-[136px] shrink-0 items-center xl:w-[152px]"
>
<Image
className="h-auto w-[108px] object-contain"
src={logo}
alt="VCCI-HCM"
width={108}
height={40}
className="h-auto max-h-10 w-[108px] object-contain"
src={currentLogo?.logo_url ? resolveUploadUrl(currentLogo.logo_url) : logo}
alt={currentLogo?.logo_name || "VCCI-HCM"}
priority
/>
</Link>
......@@ -248,7 +351,7 @@ function Header() {
</div>
</nav>
</div>
<button
onClick={() => setToggleMenu((prev) => !prev)}
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 bg-white text-[#163b73] transition hover:bg-slate-50 lg:hidden"
......@@ -260,19 +363,24 @@ function Header() {
</div>
<div
className={`fixed inset-0 z-[60] bg-white transition-all duration-300 lg:hidden ${
toggleMenu
className={`fixed inset-0 z-60 bg-white transition-all duration-300 lg:hidden ${toggleMenu
? "pointer-events-auto translate-y-0 opacity-100"
: "pointer-events-none -translate-y-2 opacity-0"
}`}
}`}
>
<div className="flex h-full flex-col overflow-hidden">
<div className="sticky top-0 z-10 flex h-[78px] shrink-0 items-center justify-between border-b border-slate-100 bg-white px-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<Link href="/" className="flex w-[136px] shrink-0 items-center" onClick={() => setToggleMenu(false)}>
<Link
href="/"
className="flex w-[136px] shrink-0 items-center"
onClick={() => setToggleMenu(false)}
>
<Image
className="h-auto w-[108px] object-contain"
src={logo}
alt="VCCI-HCM"
width={108}
height={40}
className="h-auto max-h-10 w-[108px] object-contain"
src={currentLogo?.logo_url ? resolveUploadUrl(currentLogo.logo_url) : logo}
alt={currentLogo?.logo_name || "VCCI-HCM"}
priority
/>
</Link>
......@@ -292,7 +400,8 @@ function Header() {
placeholder={"T\u00ecm ki\u1ebfm"}
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = (e.currentTarget as HTMLInputElement).value || "";
const value =
(e.currentTarget as HTMLInputElement).value || "";
const encoded = encodeURIComponent(value);
router.push(`/search?q=${encoded}&page=1`);
setToggleMenu(false);
......@@ -302,7 +411,10 @@ function Header() {
<div className="pb-6">
{menuItems.map((category) => (
<div key={category.id} className="border-t border-slate-100 first:border-t-0">
<div
key={category.id}
className="border-t border-slate-100 first:border-t-0"
>
<Link
href={category.url || "#"}
className="block px-5 py-3 text-[15px] font-medium text-slate-700 transition hover:bg-slate-50 hover:text-[#2f57ff]"
......
"use client";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
......@@ -20,7 +21,13 @@ import { AdminImagePicker } from "@/components/admin/image-picker";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
......@@ -43,11 +50,22 @@ import {
postSiteInformationBranches,
putSiteInformation,
} from "@/api/endpoints/site-information";
import { deleteLogoId, postLogo, putLogoId } from "@/api/endpoints/logo";
import { deleteBannerId, getBanner, postBanner, putBannerId } from "@/api/endpoints/banner";
import {
deleteLogoId,
getLogo,
postLogo,
putLogoId,
} from "@/api/endpoints/logo";
import {
deleteBannerId,
getBanner,
postBanner,
putBannerId,
} from "@/api/endpoints/banner";
import type {
Banner,
BannerMutate,
Logo,
SiteInformationBranch,
SiteInformationBranchMutate,
SiteInformationData,
......@@ -87,6 +105,10 @@ type LogoMediaItem = AdminMediaItem & {
logoId?: string;
};
type LogoListResponse = {
rows?: Logo[];
};
type PageEnvelope<T> = {
rows?: T[];
count?: number;
......@@ -112,7 +134,10 @@ function emptyItemForm(): ConfigItemForm {
};
}
function resolveMediaItem(mediaMap: Map<string, AdminMediaItem>, imageId: string) {
function resolveMediaItem(
mediaMap: Map<string, AdminMediaItem>,
imageId: string,
) {
return mediaMap.get(imageId) ?? null;
}
......@@ -121,7 +146,9 @@ function getEnvelopeData<T>(payload: unknown): T | undefined {
return root.responseData ?? root.data?.responseData;
}
function mapApiBranchToConfig(branch: SiteInformationBranch): BaseConfigBranchItem {
function mapApiBranchToConfig(
branch: SiteInformationBranch,
): BaseConfigBranchItem {
return {
id: branch.id ?? createBaseConfigItemId("branch"),
branchName: branch.branch_name ?? "",
......@@ -146,12 +173,16 @@ function mapConfigBranchToApi(
email: branch.email.trim() || null,
fax: branch.fax.trim() || null,
googlemap_link: branch.mapsEmbedUrl.trim() || null,
sort_order: Number.isFinite(branch.sortOrder) ? branch.sortOrder : index + 1,
sort_order: Number.isFinite(branch.sortOrder)
? branch.sortOrder
: index + 1,
is_active: branch.isVisible,
};
}
function mapApiSocialToConfig(social: SiteInformationSocialLink): BaseConfigSocialItem {
function mapApiSocialToConfig(
social: SiteInformationSocialLink,
): BaseConfigSocialItem {
return {
id: social.id,
label: social.label,
......@@ -161,7 +192,9 @@ function mapApiSocialToConfig(social: SiteInformationSocialLink): BaseConfigSoci
};
}
function mapConfigSocialToApi(social: BaseConfigSocialItem): SiteInformationSocialMutate {
function mapConfigSocialToApi(
social: BaseConfigSocialItem,
): SiteInformationSocialMutate {
return {
url: social.url.trim() || null,
sort_order: social.sortOrder,
......@@ -190,13 +223,10 @@ function mapConfigBannerToApi(banner: BaseConfigBannerItem): BannerMutate {
};
}
function mapSiteLogoToConfig(siteInformation: SiteInformationData): {
function mapApiLogoToConfig(logo: Logo): {
logo: BaseConfigLogoItem | null;
media: LogoMediaItem | null;
} | null {
const logo = siteInformation.logo;
if (!logo) return null;
const media: LogoMediaItem = {
id: logo.file_id,
logoId: logo.id,
......@@ -224,8 +254,9 @@ function mapSiteLogoToConfig(siteInformation: SiteInformationData): {
function applySiteInformationToConfig(
baseConfig: BaseConfigData,
siteInformation: SiteInformationData,
logo?: Logo | null,
): BaseConfigData {
const logoConfig = mapSiteLogoToConfig(siteInformation);
const logoConfig = logo ? mapApiLogoToConfig(logo) : null;
return {
...baseConfig,
......@@ -266,7 +297,12 @@ function ConfigItemPreview({
>
<div className="relative aspect-[16/10] overflow-hidden bg-[#eef4ff]">
{media ? (
<SafeNextImage src={media.url} alt={media.alt || media.name} fill className="object-cover" />
<SafeNextImage
src={media.url}
alt={media.alt || media.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Chưa chọn hình ảnh
......@@ -274,7 +310,9 @@ function ConfigItemPreview({
)}
</div>
<div className="space-y-2 px-4 py-3">
<div className="line-clamp-1 text-sm font-semibold text-[#163b73]">{title}</div>
<div className="line-clamp-1 text-sm font-semibold text-[#163b73]">
{title}
</div>
<div className="line-clamp-2 text-sm text-gray-600">{item.name}</div>
</div>
</button>
......@@ -302,7 +340,10 @@ function ConfigItemDialog({
title: string;
description: string;
onOpenChange: (open: boolean) => void;
onChange: <K extends keyof ConfigItemForm>(key: K, value: ConfigItemForm[K]) => void;
onChange: <K extends keyof ConfigItemForm>(
key: K,
value: ConfigItemForm[K],
) => void;
onPickImage: () => void;
onSubmit: () => void;
}) {
......@@ -311,8 +352,12 @@ function ConfigItemDialog({
<DialogContent className="flex max-h-[88vh] max-w-xl flex-col overflow-hidden rounded-3xl border-[#063e8e]/15 bg-white p-0">
<DialogHeader>
<div className="border-b border-[#063e8e]/10 px-6 py-5">
<DialogTitle className="text-xl text-[#063e8e]">{title}</DialogTitle>
<DialogDescription className="mt-2 text-sm text-gray-600">{description}</DialogDescription>
<DialogTitle className="text-xl text-[#063e8e]">
{title}
</DialogTitle>
<DialogDescription className="mt-2 text-sm text-gray-600">
{description}
</DialogDescription>
</div>
</DialogHeader>
......@@ -323,21 +368,28 @@ function ConfigItemDialog({
<Input
value={form.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder={mode === "logo" ? "Nhập tên logo..." : "Nhập tên banner..."}
placeholder={
mode === "logo" ? "Nhập tên logo..." : "Nhập tên banner..."
}
className={fieldClassName}
/>
</div>
{mode === "banner" ? (
<div className="space-y-2">
<Label className="text-gray-700">Thời gian hiển thị (giây)</Label>
<Label className="text-gray-700">
Thời gian hiển thị (giây)
</Label>
<Input
type="number"
min={1}
max={60}
value={form.displayTimeSeconds}
onChange={(event) =>
onChange("displayTimeSeconds", Number(event.target.value || 1))
onChange(
"displayTimeSeconds",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
......@@ -351,7 +403,9 @@ function ConfigItemDialog({
type="number"
min={1}
value={form.sortOrder}
onChange={(event) => onChange("sortOrder", Number(event.target.value || 1))}
onChange={(event) =>
onChange("sortOrder", Number(event.target.value || 1))
}
className={fieldClassName}
/>
</div>
......@@ -388,13 +442,18 @@ function ConfigItemDialog({
{mode === "banner" ? (
<div className="flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-[#f7faff] px-4 py-3">
<div>
<div className="text-sm font-medium text-[#163b73]">Trạng thái hiển thị</div>
<div className="text-xs text-gray-500">
{form.isActive ? "Đang bật hiển thị" : "Đang tắt hiển thị"}
<div>
<div className="text-sm font-medium text-[#163b73]">
Trạng thái hiển thị
</div>
<div className="text-xs text-gray-500">
{form.isActive ? "Đang bật hiển thị" : "Đang tắt hiển thị"}
</div>
</div>
</div>
<Switch checked={form.isActive} onCheckedChange={(value) => onChange("isActive", value)} />
<Switch
checked={form.isActive}
onCheckedChange={(value) => onChange("isActive", value)}
/>
</div>
) : null}
</div>
......@@ -446,14 +505,18 @@ function BranchCard({
}`}
>
<button type="button" onClick={onSelect} className="w-full text-left">
<div className="text-sm font-semibold text-[#163b73]">{branch.branchName || "Chi nhánh mới"}</div>
<div className="text-sm font-semibold text-[#163b73]">
{branch.branchName || "Chi nhánh mới"}
</div>
<div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-600">
{branch.address || "Chưa cập nhật địa chỉ"}
</div>
</button>
<div className="mt-4 flex items-center justify-between">
<div className="text-xs text-slate-500">{branch.hotline || "Chưa có hotline"}</div>
<div className="text-xs text-slate-500">
{branch.hotline || "Chưa có hotline"}
</div>
<Button
type="button"
variant="ghost"
......@@ -469,16 +532,21 @@ function BranchCard({
}
export default function AdminBaseConfigPage() {
const queryClient = useQueryClient();
const [config, setConfig] = React.useState<BaseConfigData | null>(null);
const [mediaItems, setMediaItems] = React.useState<AdminMediaItem[]>([]);
const [currentBannerIndex, setCurrentBannerIndex] = React.useState(0);
const [currentBranchIndex, setCurrentBranchIndex] = React.useState(0);
const [currentBranchId, setCurrentBranchId] = React.useState<string | null>(null);
const [currentBranchId, setCurrentBranchId] = React.useState<string | null>(
null,
);
const [activeTab, setActiveTab] = React.useState("branding");
const [itemDialogOpen, setItemDialogOpen] = React.useState(false);
const [itemDialogMode, setItemDialogMode] = React.useState<ConfigItemMode>("logo");
const [itemDialogMode, setItemDialogMode] =
React.useState<ConfigItemMode>("logo");
const [editingItemId, setEditingItemId] = React.useState<string | null>(null);
const [itemForm, setItemForm] = React.useState<ConfigItemForm>(emptyItemForm());
const [itemForm, setItemForm] =
React.useState<ConfigItemForm>(emptyItemForm());
const [imagePickerOpen, setImagePickerOpen] = React.useState(false);
const [savingItem, setSavingItem] = React.useState(false);
const [savingWebsiteInfo, setSavingWebsiteInfo] = React.useState(false);
......@@ -497,24 +565,63 @@ export default function AdminBaseConfigPage() {
const loadSiteInformation = async () => {
try {
const response = await getSiteInformation();
const siteInformation = getEnvelopeData<SiteInformationData>(response);
const [siteInformationResponse, logoResponse] = await Promise.all([
getSiteInformation(),
getLogo({
page: 1,
pageSize: 1,
sortField: "updated_at",
sortOrder: "desc",
}),
]);
const siteInformation = getEnvelopeData<SiteInformationData>(
siteInformationResponse,
);
const logoPage = getEnvelopeData<LogoListResponse>(logoResponse);
const currentLogo = logoPage?.rows?.[0] ?? null;
if (!mounted || !siteInformation) return;
if (!mounted) return;
const logoConfig = mapSiteLogoToConfig(siteInformation);
if (logoConfig?.media) {
const logoMedia = logoConfig.media;
setMediaItems((previous) => {
const nextMap = new Map(previous.map((entry) => [entry.id, entry]));
nextMap.set(logoMedia.id, logoMedia);
return Array.from(nextMap.values());
});
if (currentLogo) {
const logoConfig = mapApiLogoToConfig(currentLogo);
if (logoConfig?.media) {
const logoMedia = logoConfig.media;
setMediaItems((previous) => {
const nextMap = new Map(
previous.map((entry) => [entry.id, entry]),
);
nextMap.set(logoMedia.id, logoMedia);
return Array.from(nextMap.values());
});
}
}
setConfig((previous) =>
applySiteInformationToConfig(previous ?? baseConfig, siteInformation),
);
if (siteInformation) {
setConfig((previous) =>
applySiteInformationToConfig(
previous ?? baseConfig,
siteInformation,
currentLogo,
),
);
return;
}
if (currentLogo) {
const logoConfig = mapApiLogoToConfig(currentLogo);
setConfig((previous) =>
previous
? {
...previous,
logo: logoConfig?.logo ?? previous.logo,
}
: {
...baseConfig,
logo: logoConfig?.logo ?? baseConfig.logo,
},
);
}
} catch (error) {
console.error(error);
if (mounted) {
......@@ -600,10 +707,14 @@ export default function AdminBaseConfigPage() {
const currentLogo = config?.logo ?? null;
const currentBanner = sortedBanners[currentBannerIndex] ?? null;
const currentBranch =
(currentBranchId ? sortedBranches.find((branch) => branch.id === currentBranchId) : null) ??
(currentBranchId
? sortedBranches.find((branch) => branch.id === currentBranchId)
: null) ??
sortedBranches[currentBranchIndex] ??
null;
const currentLogoMedia = currentLogo ? resolveMediaItem(mediaMap, currentLogo.imageId) : null;
const currentLogoMedia = currentLogo
? resolveMediaItem(mediaMap, currentLogo.imageId)
: null;
const currentBannerMedia = currentBanner
? resolveMediaItem(mediaMap, currentBanner.imageId)
: null;
......@@ -619,19 +730,24 @@ export default function AdminBaseConfigPage() {
setEditingItemId(null);
setItemForm({
...emptyItemForm(),
sortOrder: mode === "banner" ? (config ? config.banners.length + 1 : 1) : 1,
sortOrder:
mode === "banner" ? (config ? config.banners.length + 1 : 1) : 1,
});
setItemDialogOpen(true);
};
const openEditDialog = (mode: ConfigItemMode, item: BaseConfigLogoItem | BaseConfigBannerItem) => {
const openEditDialog = (
mode: ConfigItemMode,
item: BaseConfigLogoItem | BaseConfigBannerItem,
) => {
setItemDialogMode(mode);
setEditingItemId(item.id);
setItemForm({
name: item.name,
imageId: item.imageId,
isActive: item.isActive,
displayTimeSeconds: "displayTimeSeconds" in item ? item.displayTimeSeconds : 5,
displayTimeSeconds:
"displayTimeSeconds" in item ? item.displayTimeSeconds : 5,
sortOrder: "sortOrder" in item ? item.sortOrder : 1,
});
setItemDialogOpen(true);
......@@ -669,7 +785,8 @@ export default function AdminBaseConfigPage() {
logo_url: selectedMedia?.url ?? null,
file_id: itemForm.imageId,
});
const savedLogo = getEnvelopeData<NonNullable<SiteInformationData["logo"]>>(response);
const savedLogo =
getEnvelopeData<NonNullable<SiteInformationData["logo"]>>(response);
const nextConfig = cloneBaseConfigData(config);
nextConfig.logo = {
......@@ -682,6 +799,7 @@ export default function AdminBaseConfigPage() {
saveConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ["/logo"] });
toast.success("Đã lưu cấu hình logo");
} catch (error) {
console.error(error);
......@@ -705,7 +823,9 @@ export default function AdminBaseConfigPage() {
? await putBannerId(editingItemId, mapConfigBannerToApi(bannerDraft))
: await postBanner(mapConfigBannerToApi(bannerDraft));
const savedBanner = getEnvelopeData<Banner>(response);
const nextBanner = savedBanner ? mapApiBannerToConfig(savedBanner) : bannerDraft;
const nextBanner = savedBanner
? mapApiBannerToConfig(savedBanner)
: bannerDraft;
const nextConfig = cloneBaseConfigData(config);
if (editingItemId) {
......@@ -731,38 +851,34 @@ export default function AdminBaseConfigPage() {
const nextConfig = cloneBaseConfigData(config!);
if (editingItemId) {
nextConfig.banners = nextConfig.banners.map((item) =>
item.id === editingItemId
? {
...item,
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
}
: item,
);
nextConfig.banners = nextConfig.banners.map((item) =>
item.id === editingItemId
? {
...item,
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
}
: item,
);
} else {
nextConfig.banners.push({
id: createBaseConfigItemId("banner"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
});
setCurrentBannerIndex(Math.max(nextConfig.banners.length - 1, 0));
nextConfig.banners.push({
id: createBaseConfigItemId("banner"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
});
setCurrentBannerIndex(Math.max(nextConfig.banners.length - 1, 0));
}
saveConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
toast.success(
false
? "Đã lưu cấu hình logo"
: "Đã lưu cấu hình banner",
);
toast.success(false ? "Đã lưu cấu hình logo" : "Đã lưu cấu hình banner");
};
const handleDeleteItem = async () => {
......@@ -771,28 +887,33 @@ export default function AdminBaseConfigPage() {
try {
const nextConfig = cloneBaseConfigData(config);
if (deleteTarget.mode === "logo") {
try {
await deleteLogoId(deleteTarget.id);
nextConfig.logo = null;
} catch (error) {
console.error(error);
toast.error("Không thể xóa cấu hình logo");
setDeleteTarget(null);
return;
if (deleteTarget.mode === "logo") {
try {
await deleteLogoId(deleteTarget.id);
nextConfig.logo = null;
queryClient.invalidateQueries({ queryKey: ["/logo"] });
} catch (error) {
console.error(error);
toast.error("Không thể xóa cấu hình logo");
setDeleteTarget(null);
return;
}
} else {
await deleteBannerId(deleteTarget.id);
nextConfig.banners = nextConfig.banners.filter(
(item) => item.id !== deleteTarget.id,
);
setCurrentBannerIndex((previous) =>
Math.max(0, Math.min(previous, nextConfig.banners.length - 1)),
);
}
} else {
await deleteBannerId(deleteTarget.id);
nextConfig.banners = nextConfig.banners.filter((item) => item.id !== deleteTarget.id);
setCurrentBannerIndex((previous) =>
Math.max(0, Math.min(previous, nextConfig.banners.length - 1)),
);
}
saveConfig(nextConfig);
toast.success("Đã xóa cấu hình");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Không thể xóa cấu hình");
toast.error(
err instanceof Error ? err.message : "Không thể xóa cấu hình",
);
} finally {
setDeleteTarget(null);
}
......@@ -809,7 +930,9 @@ export default function AdminBaseConfigPage() {
? {
...previous,
branches: previous.branches.map((branch) =>
branch.id === currentBranch.id ? { ...branch, [key]: value } : branch,
branch.id === currentBranch.id
? { ...branch, [key]: value }
: branch,
),
}
: previous,
......@@ -836,10 +959,15 @@ export default function AdminBaseConfigPage() {
if (!config) return;
const nextConfig = cloneBaseConfigData(config);
nextConfig.branches = nextConfig.branches.filter((branch) => branch.id !== branchId);
nextConfig.branches = nextConfig.branches.filter(
(branch) => branch.id !== branchId,
);
saveConfig(nextConfig);
setCurrentBranchIndex((previous) =>
Math.max(0, Math.min(previous, Math.max(nextConfig.branches.length - 1, 0))),
Math.max(
0,
Math.min(previous, Math.max(nextConfig.branches.length - 1, 0)),
),
);
toast.success("Đã xóa chi nhánh");
};
......@@ -901,10 +1029,15 @@ export default function AdminBaseConfigPage() {
await deleteSiteInformationBranchesId(branchId);
const nextConfig = cloneBaseConfigData(config);
nextConfig.branches = nextConfig.branches.filter((branch) => branch.id !== branchId);
nextConfig.branches = nextConfig.branches.filter(
(branch) => branch.id !== branchId,
);
setConfig(nextConfig);
setCurrentBranchIndex((previous) =>
Math.max(0, Math.min(previous, Math.max(nextConfig.branches.length - 1, 0))),
Math.max(
0,
Math.min(previous, Math.max(nextConfig.branches.length - 1, 0)),
),
);
setCurrentBranchId(null);
toast.success("Đã xóa chi nhánh");
......@@ -923,7 +1056,10 @@ export default function AdminBaseConfigPage() {
try {
await Promise.all(
sortBaseConfigBranches(config.branches).map((branch, index) =>
patchSiteInformationBranchesId(branch.id, mapConfigBranchToApi(branch, index)),
patchSiteInformationBranchesId(
branch.id,
mapConfigBranchToApi(branch, index),
),
),
);
setConfig(config);
......@@ -936,8 +1072,13 @@ export default function AdminBaseConfigPage() {
}
};
const handleWebsiteInfoChange = (key: "websiteName" | "websiteLink", value: string) => {
setConfig((previous) => (previous ? { ...previous, [key]: value } : previous));
const handleWebsiteInfoChange = (
key: "websiteName" | "websiteLink",
value: string,
) => {
setConfig((previous) =>
previous ? { ...previous, [key]: value } : previous,
);
};
const handleSaveWebsiteInfo = async () => {
......@@ -998,7 +1139,10 @@ export default function AdminBaseConfigPage() {
try {
await Promise.all(
config.socials.map((social) =>
patchSiteInformationSocialsId(social.id, mapConfigSocialToApi(social)),
patchSiteInformationSocialsId(
social.id,
mapConfigSocialToApi(social),
),
),
);
setConfig(config);
......@@ -1021,33 +1165,37 @@ export default function AdminBaseConfigPage() {
return (
<div className="space-y-8">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-5">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="space-y-5"
>
<div className="overflow-x-auto pb-1">
<TabsList className="h-auto min-w-max rounded-2xl bg-[#eaf2ff] p-1.5">
<TabsTrigger
value="branding"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Nhận diện thương hiệu
</TabsTrigger>
<TabsTrigger
value="banner"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Banner trang chủ
</TabsTrigger>
<TabsTrigger
value="contact"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Thông tin liên hệ
</TabsTrigger>
<TabsTrigger
value="social"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Mạng xã hội
</TabsTrigger>
<TabsTrigger
value="branding"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Nhận diện thương hiệu
</TabsTrigger>
<TabsTrigger
value="banner"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Banner trang chủ
</TabsTrigger>
<TabsTrigger
value="contact"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Thông tin liên hệ
</TabsTrigger>
<TabsTrigger
value="social"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Mạng xã hội
</TabsTrigger>
</TabsList>
</div>
......@@ -1056,7 +1204,9 @@ export default function AdminBaseConfigPage() {
<CardHeader className="pb-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Nhận diện thương hiệu</CardTitle>
<CardTitle className="text-2xl text-[#163b73]">
Nhận diện thương hiệu
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý logo hiển thị trên website.
</CardDescription>
......@@ -1133,13 +1283,20 @@ export default function AdminBaseConfigPage() {
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-[#4b74b8]">
Logo website
</div>
<div className="mt-3 font-semibold text-[#163b73]">{currentLogo.name}</div>
<div className="mt-3 font-semibold text-[#163b73]">
{currentLogo.name}
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Tên website</Label>
<Input
value={config.websiteName}
onChange={(event) => handleWebsiteInfoChange("websiteName", event.target.value)}
onChange={(event) =>
handleWebsiteInfoChange(
"websiteName",
event.target.value,
)
}
className={fieldClassName}
/>
</div>
......@@ -1147,7 +1304,12 @@ export default function AdminBaseConfigPage() {
<Label className="text-gray-700">Link website</Label>
<Input
value={config.websiteLink}
onChange={(event) => handleWebsiteInfoChange("websiteLink", event.target.value)}
onChange={(event) =>
handleWebsiteInfoChange(
"websiteLink",
event.target.value,
)
}
className={fieldClassName}
/>
</div>
......@@ -1156,7 +1318,10 @@ export default function AdminBaseConfigPage() {
Trạng thái
</div>
<div className="mt-2 flex items-center gap-2">
<Badge variant="outline" className="border-[#063e8e]/20 text-[#063e8e]">
<Badge
variant="outline"
className="border-[#063e8e]/20 text-[#063e8e]"
>
{currentLogo.isActive ? "Đang hiển thị" : "Đang ẩn"}
</Badge>
</div>
......@@ -1168,7 +1333,9 @@ export default function AdminBaseConfigPage() {
className="w-full rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
{savingWebsiteInfo ? "Đang lưu..." : "Lưu thông tin website"}
{savingWebsiteInfo
? "Đang lưu..."
: "Lưu thông tin website"}
</Button>
</div>
) : (
......@@ -1187,9 +1354,12 @@ export default function AdminBaseConfigPage() {
<CardHeader className="pb-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Banner trang chủ</CardTitle>
<CardTitle className="text-2xl text-[#163b73]">
Banner trang chủ
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý hình ảnh slider chỉ dùng cho khu vực banner trang chủ của website.
Quản lý hình ảnh slider chỉ dùng cho khu vực banner trang
chủ của website.
</CardDescription>
</div>
......@@ -1264,7 +1434,9 @@ export default function AdminBaseConfigPage() {
className="rounded-xl border-[#063e8e]/15"
onClick={() =>
setCurrentBannerIndex((previous) =>
previous <= 0 ? Math.max(sortedBanners.length - 1, 0) : previous - 1,
previous <= 0
? Math.max(sortedBanners.length - 1, 0)
: previous - 1,
)
}
disabled={sortedBanners.length <= 1}
......@@ -1278,7 +1450,9 @@ export default function AdminBaseConfigPage() {
className="rounded-xl border-[#063e8e]/15"
onClick={() =>
setCurrentBannerIndex((previous) =>
sortedBanners.length === 0 ? 0 : (previous + 1) % sortedBanners.length,
sortedBanners.length === 0
? 0
: (previous + 1) % sortedBanners.length,
)
}
disabled={sortedBanners.length <= 1}
......@@ -1304,14 +1478,20 @@ export default function AdminBaseConfigPage() {
{currentBanner ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">Tên banner</div>
<div className="mt-2 font-semibold text-[#163b73]">{currentBanner.name}</div>
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Tên banner
</div>
<div className="mt-2 font-semibold text-[#163b73]">
{currentBanner.name}
</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Thứ tự hiển thị
</div>
<div className="mt-2 font-semibold text-[#163b73]">{currentBanner.sortOrder}</div>
<div className="mt-2 font-semibold text-[#163b73]">
{currentBanner.sortOrder}
</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
......@@ -1322,9 +1502,14 @@ export default function AdminBaseConfigPage() {
</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">Trạng thái</div>
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Trạng thái
</div>
<div className="mt-2">
<Badge variant="outline" className="border-[#063e8e]/20 text-[#063e8e]">
<Badge
variant="outline"
className="border-[#063e8e]/20 text-[#063e8e]"
>
{currentBanner.isActive ? "Đang hiển thị" : "Đang ẩn"}
</Badge>
</div>
......@@ -1340,7 +1525,9 @@ export default function AdminBaseConfigPage() {
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Thông tin liên hệ website</CardTitle>
<CardTitle className="text-2xl text-[#163b73]">
Thông tin liên hệ website
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý nhiều địa chỉ chi nhánh để hiển thị trên website.
</CardDescription>
......@@ -1396,7 +1583,9 @@ export default function AdminBaseConfigPage() {
<Label className="text-gray-700">Tên chi nhánh</Label>
<Input
value={currentBranch.branchName}
onChange={(event) => handleBranchChange("branchName", event.target.value)}
onChange={(event) =>
handleBranchChange("branchName", event.target.value)
}
className={fieldClassName}
/>
</div>
......@@ -1405,7 +1594,9 @@ export default function AdminBaseConfigPage() {
<Label className="text-gray-700">Địa chỉ</Label>
<Textarea
value={currentBranch.address}
onChange={(event) => handleBranchChange("address", event.target.value)}
onChange={(event) =>
handleBranchChange("address", event.target.value)
}
className={`${fieldClassName} min-h-[110px]`}
/>
</div>
......@@ -1415,7 +1606,9 @@ export default function AdminBaseConfigPage() {
<Label className="text-gray-700">Hotline</Label>
<Input
value={currentBranch.hotline}
onChange={(event) => handleBranchChange("hotline", event.target.value)}
onChange={(event) =>
handleBranchChange("hotline", event.target.value)
}
className={fieldClassName}
/>
</div>
......@@ -1423,7 +1616,9 @@ export default function AdminBaseConfigPage() {
<Label className="text-gray-700">Email</Label>
<Input
value={currentBranch.email}
onChange={(event) => handleBranchChange("email", event.target.value)}
onChange={(event) =>
handleBranchChange("email", event.target.value)
}
className={fieldClassName}
/>
</div>
......@@ -1434,7 +1629,9 @@ export default function AdminBaseConfigPage() {
<Label className="text-gray-700">Fax</Label>
<Input
value={currentBranch.fax}
onChange={(event) => handleBranchChange("fax", event.target.value)}
onChange={(event) =>
handleBranchChange("fax", event.target.value)
}
className={fieldClassName}
/>
</div>
......@@ -1442,7 +1639,12 @@ export default function AdminBaseConfigPage() {
<Label className="text-gray-700">Google Maps</Label>
<Input
value={currentBranch.mapsEmbedUrl}
onChange={(event) => handleBranchChange("mapsEmbedUrl", event.target.value)}
onChange={(event) =>
handleBranchChange(
"mapsEmbedUrl",
event.target.value,
)
}
className={fieldClassName}
/>
</div>
......@@ -1456,28 +1658,38 @@ export default function AdminBaseConfigPage() {
min={1}
value={currentBranch.sortOrder}
onChange={(event) =>
handleBranchChange("sortOrder", Number(event.target.value || 1))
handleBranchChange(
"sortOrder",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
</div>
<div className="flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3">
<div>
<div className="text-sm font-medium text-[#163b73]">Trạng thái hiển thị</div>
<div className="text-sm font-medium text-[#163b73]">
Trạng thái hiển thị
</div>
<div className="text-xs text-gray-500">
{currentBranch.isVisible ? "Đang hiển thị" : "Đang ẩn"}
{currentBranch.isVisible
? "Đang hiển thị"
: "Đang ẩn"}
</div>
</div>
<Switch
checked={currentBranch.isVisible}
onCheckedChange={(value) => handleBranchChange("isVisible", value)}
onCheckedChange={(value) =>
handleBranchChange("isVisible", value)
}
/>
</div>
</div>
</>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-10 text-center text-sm text-gray-500">
Chưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu hình
Chưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu
hình
</div>
)}
</div>
......@@ -1490,7 +1702,9 @@ export default function AdminBaseConfigPage() {
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Mạng xã hội</CardTitle>
<CardTitle className="text-2xl text-[#163b73]">
Mạng xã hội
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý link mạng xã hội và thứ tự hiển thị trên website.
</CardDescription>
......@@ -1519,11 +1733,17 @@ export default function AdminBaseConfigPage() {
<Checkbox
checked={item.isVisible}
onCheckedChange={(checked) =>
handleSocialChange(item.id, "isVisible", checked === true)
handleSocialChange(
item.id,
"isVisible",
checked === true,
)
}
/>
<div>
<div className="font-semibold text-[#163b73]">{item.label}</div>
<div className="font-semibold text-[#163b73]">
{item.label}
</div>
<div className="text-sm text-slate-500">
{item.isVisible ? "Đang hiển thị" : "Đang ẩn"}
</div>
......@@ -1534,7 +1754,9 @@ export default function AdminBaseConfigPage() {
<Label className="text-gray-700">Link URL</Label>
<Input
value={item.url}
onChange={(event) => handleSocialChange(item.id, "url", event.target.value)}
onChange={(event) =>
handleSocialChange(item.id, "url", event.target.value)
}
placeholder={`Nhập link ${item.label}...`}
className={fieldClassName}
/>
......@@ -1547,7 +1769,11 @@ export default function AdminBaseConfigPage() {
min={1}
value={item.sortOrder}
onChange={(event) =>
handleSocialChange(item.id, "sortOrder", Number(event.target.value || 1))
handleSocialChange(
item.id,
"sortOrder",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
......@@ -1581,7 +1807,9 @@ export default function AdminBaseConfigPage() {
: "Thiết lập banner hiển thị cho trang chủ."
}
onOpenChange={setItemDialogOpen}
onChange={(key, value) => setItemForm((previous) => ({ ...previous, [key]: value }))}
onChange={(key, value) =>
setItemForm((previous) => ({ ...previous, [key]: value }))
}
onPickImage={() => setImagePickerOpen(true)}
onSubmit={handleSubmitItem}
/>
......@@ -1605,7 +1833,8 @@ export default function AdminBaseConfigPage() {
title="Xóa cấu hình"
description={
<>
Bạn có chắc muốn xóa <span className="font-semibold">{deleteTarget?.name}</span>? Hành
Bạn có chắc muốn xóa{" "}
<span className="font-semibold">{deleteTarget?.name}</span>? Hành
động này không thể hoàn tác.
</>
}
......
......@@ -14,12 +14,15 @@ import {
ShieldCheck,
} from "lucide-react";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useGetLogo } from "@/api/endpoints/logo";
import type { Logo } from "@/api/models/logo";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import logo from "@/assets/VCCI-HCM-logo-VN-2025.png";
import links from "@/links";
import links, { resolveUploadUrl } from "@/links";
import { loginAdmin } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore";
......@@ -35,6 +38,14 @@ type ApiEnvelope<T = unknown> = {
message_en?: string | null;
};
type LogoListEnvelope = {
data?: {
responseData?: {
rows?: Logo[];
};
};
};
type VerifyOtpPayload = {
reset_token?: string;
expires_in?: number;
......@@ -58,7 +69,11 @@ const authButtonClassName =
"h-11 rounded-xl bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.16)] hover:bg-[#052f6c]";
function normalizeRedirectPath(redirect: string | null) {
if (!redirect || !redirect.startsWith("/admin") || redirect === "/admin/login") {
if (
!redirect ||
!redirect.startsWith("/admin") ||
redirect === "/admin/login"
) {
return DEFAULT_REDIRECT;
}
......@@ -95,7 +110,8 @@ async function postAuthJson<TResponse, TBody>(path: string, body: TBody) {
body: JSON.stringify(body),
});
const data = (await response.json().catch(() => ({}))) as TResponse & ErrorResponse;
const data = (await response.json().catch(() => ({}))) as TResponse &
ErrorResponse;
if (!response.ok) {
throw {
......@@ -115,6 +131,23 @@ function AuthShell({
mode: AuthMode;
children: React.ReactNode;
}) {
const { data: logoData } = useGetLogo(
{
page: 1,
pageSize: 1,
sortField: "updated_at",
sortOrder: "desc",
},
{
query: {
select: (response: any) => {
const responseData = response?.responseData ?? response?.data?.responseData;
return (responseData?.rows?.[0] as Logo | undefined) ?? null;
},
},
}
);
const title =
mode === "login"
? "Đăng nhập quản trị"
......@@ -139,13 +172,22 @@ function AuthShell({
<div>
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-white shadow-sm">
<Image src={logo} alt="VCCI HCM" className="h-12 w-12 object-contain" priority />
<Image
src={logoData?.logo_url ? resolveUploadUrl(logoData.logo_url) : logo}
alt={logoData?.logo_name || "VCCI HCM"}
width={48}
height={48}
className="h-12 w-12 object-contain"
priority
/>
</div>
<div>
<div className="text-sm font-bold uppercase tracking-[0.2em] text-[#063e8e]">
VCCI News
{logoData?.logo_name || "VCCI News"}
</div>
<div className="mt-1 text-sm text-gray-700">
Trang quản trị website
</div>
<div className="mt-1 text-sm text-gray-700">Trang quản trị website</div>
</div>
</div>
......@@ -158,16 +200,21 @@ function AuthShell({
Quản lý nội dung với giao diện riêng cho admin.
</h1>
<p className="mt-5 text-base leading-7 text-gray-700">
Hệ thống sử dụng tài khoản quản trị để bảo vệ cấu hình website, bài viết,
media và các dữ liệu vận hành.
Hệ thống sử dụng tài khoản quản trị để bảo vệ cấu hình
website, bài viết, media và các dữ liệu vận hành.
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
{["Cấu hình", "Bài viết", "Liên hệ"].map((item) => (
<div key={item} className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3">
<div className="text-sm font-semibold text-[#063e8e]">{item}</div>
<div
key={item}
className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3"
>
<div className="text-sm font-semibold text-[#063e8e]">
{item}
</div>
<div className="mt-1 h-1.5 rounded-full bg-[#dbe8ff]" />
</div>
))}
......@@ -179,13 +226,22 @@ function AuthShell({
<div className="mx-auto w-full max-w-md">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff]">
<Image src={logo} alt="VCCI HCM" className="h-9 w-9 object-contain" priority />
<Image
src={logoData?.logo_url ? resolveUploadUrl(logoData.logo_url) : logo}
alt={logoData?.logo_name || "VCCI HCM"}
width={36}
height={36}
className="h-9 w-9 object-contain"
priority
/>
</div>
<div>
<div className="text-sm font-bold uppercase tracking-[0.2em] text-[#063e8e]">
VCCI News
{logoData?.logo_name || "VCCI News"}
</div>
<div className="text-sm text-gray-700">
Trang quản trị website
</div>
<div className="text-sm text-gray-700">Trang quản trị website</div>
</div>
</div>
......@@ -193,8 +249,12 @@ function AuthShell({
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]">
<LockKeyhole className="h-6 w-6" />
</div>
<h2 className="mt-5 text-2xl font-bold text-gray-900">{title}</h2>
<p className="mt-2 text-sm leading-6 text-gray-700">{description}</p>
<h2 className="mt-5 text-2xl font-bold text-gray-900">
{title}
</h2>
<p className="mt-2 text-sm leading-6 text-gray-700">
{description}
</p>
</div>
{children}
......@@ -263,7 +323,9 @@ function InlineMessage({
}
>
<div className="flex items-start gap-2">
{type === "success" ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" /> : null}
{type === "success" ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : null}
<span>{message}</span>
</div>
</div>
......@@ -314,12 +376,18 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
try {
await loginAdmin(email.trim(), password, { persistSession: remember });
setAppUserRemember(remember ? email.trim() : "", remember ? password : "", remember);
setAppUserRemember(
remember ? email.trim() : "",
remember ? password : "",
remember,
);
toast.success("Đăng nhập quản trị thành công");
router.replace(redirect);
} catch (error) {
setLoginError(getAuthErrorMessage(error, "Đăng nhập thất bại. Vui lòng thử lại."));
setLoginError(
getAuthErrorMessage(error, "Đăng nhập thất bại. Vui lòng thử lại."),
);
} finally {
setLoginLoading(false);
}
......@@ -332,15 +400,20 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading(true);
try {
await postAuthJson<ApiEnvelope, { email: string }>("/auth/forgot-password/send-otp", {
email: email.trim(),
});
await postAuthJson<ApiEnvelope, { email: string }>(
"/auth/forgot-password/send-otp",
{
email: email.trim(),
},
);
setResetStep("verify");
setMode("reset");
setResetMessage("M? OTP d? du?c g?i d?n email qu?n tr?.");
} catch (error) {
setResetError(getAuthErrorMessage(error, "Kh?ng th? g?i m? OTP. Vui l?ng th? l?i."));
setResetError(
getAuthErrorMessage(error, "Kh?ng th? g?i m? OTP. Vui l?ng th? l?i."),
);
} finally {
setResetLoading(false);
}
......@@ -353,13 +426,13 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading(true);
try {
const response = await postAuthJson<ApiEnvelope<VerifyOtpPayload>, { email: string; otp: string }>(
"/auth/forgot-password/verify-otp",
{
email: email.trim(),
otp: otp.trim(),
},
);
const response = await postAuthJson<
ApiEnvelope<VerifyOtpPayload>,
{ email: string; otp: string }
>("/auth/forgot-password/verify-otp", {
email: email.trim(),
otp: otp.trim(),
});
const payload = getResponseData<VerifyOtpPayload>(response);
if (!payload?.reset_token) {
......@@ -370,7 +443,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetStep("password");
setResetMessage("OTP hợp lệ. Bạn có thể tạo mật khẩu mới.");
} catch (error) {
setResetError(getAuthErrorMessage(error, "OTP kh?ng h?p l? ho?c d? h?t h?n."));
setResetError(
getAuthErrorMessage(error, "OTP kh?ng h?p l? ho?c d? h?t h?n."),
);
} finally {
setResetLoading(false);
}
......@@ -394,22 +469,29 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading(true);
try {
await postAuthJson<ApiEnvelope, { reset_token: string; new_password: string }>(
"/auth/forgot-password/reset",
{
reset_token: resetToken,
new_password: newPassword,
},
);
await postAuthJson<
ApiEnvelope,
{ reset_token: string; new_password: string }
>("/auth/forgot-password/reset", {
reset_token: resetToken,
new_password: newPassword,
});
setResetStep("done");
setResetMessage("Đặt lại mật khẩu thành công. Bạn có thể đăng nhập bằng mật khẩu mới.");
setResetMessage(
"Đặt lại mật khẩu thành công. Bạn có thể đăng nhập bằng mật khẩu mới.",
);
setPassword("");
setNewPassword("");
setConfirmPassword("");
toast.success("Đặt lại mật khẩu thành công");
} catch (error) {
setResetError(getAuthErrorMessage(error, "Không thể đặt lại mật khẩu. Vui lòng thử lại."));
setResetError(
getAuthErrorMessage(
error,
"Không thể đặt lại mật khẩu. Vui lòng thử lại.",
),
);
} finally {
setResetLoading(false);
}
......@@ -442,7 +524,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<AuthShell mode={mode}>
{mode === "login" ? (
<form className="space-y-5" onSubmit={handleLogin}>
{loginError ? <InlineMessage type="error" message={loginError} /> : null}
{loginError ? (
<InlineMessage type="error" message={loginError} />
) : null}
<div className="space-y-2">
<Label htmlFor="admin-email" className="text-gray-700">
......@@ -500,7 +584,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
</Button>
</div>
<Button type="submit" className={authButtonClassName} disabled={loginLoading}>
<Button
type="submit"
className={authButtonClassName}
disabled={loginLoading}
>
{loginLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
......@@ -515,8 +603,12 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
{mode === "forgot" ? (
<form className="space-y-5" onSubmit={handleSendOtp}>
{resetError ? <InlineMessage type="error" message={resetError} /> : null}
{resetMessage ? <InlineMessage type="success" message={resetMessage} /> : null}
{resetError ? (
<InlineMessage type="error" message={resetError} />
) : null}
{resetMessage ? (
<InlineMessage type="success" message={resetMessage} />
) : null}
<div className="space-y-2">
<Label htmlFor="forgot-email" className="text-gray-700">
......@@ -537,7 +629,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
</div>
</div>
<Button type="submit" className={authButtonClassName} disabled={resetLoading}>
<Button
type="submit"
className={authButtonClassName}
disabled={resetLoading}
>
{resetLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
......@@ -569,7 +665,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
["done", "Hoàn tất"],
].map(([step, label]) => {
const stepIndex = ["verify", "password", "done"].indexOf(step);
const currentIndex = ["verify", "password", "done"].indexOf(resetStep);
const currentIndex = ["verify", "password", "done"].indexOf(
resetStep,
);
const active = currentIndex >= stepIndex;
return (
......@@ -587,8 +685,12 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
})}
</div>
{resetError ? <InlineMessage type="error" message={resetError} /> : null}
{resetMessage ? <InlineMessage type="success" message={resetMessage} /> : null}
{resetError ? (
<InlineMessage type="error" message={resetError} />
) : null}
{resetMessage ? (
<InlineMessage type="success" message={resetMessage} />
) : null}
{resetStep === "verify" ? (
<form className="space-y-5" onSubmit={handleVerifyOtp}>
......@@ -601,13 +703,19 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
inputMode="numeric"
autoComplete="one-time-code"
value={otp}
onChange={(event) => setOtp(event.target.value.replace(/\D/g, "").slice(0, 6))}
onChange={(event) =>
setOtp(event.target.value.replace(/\D/g, "").slice(0, 6))
}
placeholder="Nhập 6 chữ số"
className={`${authFieldClassName} text-center text-lg font-semibold tracking-[0.35em]`}
required
/>
</div>
<Button type="submit" className={authButtonClassName} disabled={resetLoading}>
<Button
type="submit"
className={authButtonClassName}
disabled={resetLoading}
>
{resetLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
......@@ -656,7 +764,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
/>
</div>
<Button type="submit" className={authButtonClassName} disabled={resetLoading}>
<Button
type="submit"
className={authButtonClassName}
disabled={resetLoading}
>
{resetLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
......@@ -673,12 +785,19 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<div className="space-y-5">
<div className="rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-[#063e8e]" />
<div className="mt-3 text-base font-semibold text-gray-900">M?t kh?u d? du?c c?p nh?t</div>
<div className="mt-3 text-base font-semibold text-gray-900">
M?t kh?u d? du?c c?p nh?t
</div>
<p className="mt-2 text-sm leading-6 text-gray-700">
Quay lại màn đăng nhập để vào khu vực quản trị bằng mật khẩu mới.
Quay lại màn đăng nhập để vào khu vực quản trị bằng mật khẩu
mới.
</p>
</div>
<Button type="button" className={authButtonClassName} onClick={switchToLogin}>
<Button
type="button"
className={authButtonClassName}
onClick={switchToLogin}
>
Đăng nhập ngay
</Button>
</div>
......
'use client';
"use client";
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
ChevronDown,
Globe,
......@@ -14,12 +14,23 @@ import {
Settings,
Sparkles,
Tags,
Users,
Video,
} from 'lucide-react';
import logo from '@/assets/VCCI-HCM-logo-VN-2025.png';
import { useSidebarStore } from '@/hooks/use-admin-sidebar';
import { cn } from '@/lib/utils';
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useGetLogo } from "@/api/endpoints/logo";
import type { Logo } from "@/api/models/logo";
import logo from "@/assets/VCCI-HCM-logo-VN-2025.png";
import { resolveUploadUrl } from "@/links";
type LogoListEnvelope = {
data?: {
responseData?: {
rows?: Logo[];
};
};
};
import { useSidebarStore } from "@/hooks/use-admin-sidebar";
import { cn } from "@/lib/utils";
type NavChild = { name: string; href: string };
type NavItem = {
......@@ -30,11 +41,11 @@ type NavItem = {
};
const navigation: NavItem[] = [
{ name: 'Cấu hình chung', href: '/admin/base-config', icon: Settings },
{ name: 'Cấu hình danh mục', href: '/admin/header-config', icon: Layers },
{ name: 'Quản lý bài viết', href: '/admin/news', icon: Newspaper },
{ name: 'Quản lý tag tìm kiếm', href: '/admin/tags', icon: Tags },
{ name: 'Quản lý video', href: '/admin/videos', icon: Video },
{ name: "Cấu hình chung", href: "/admin/base-config", icon: Settings },
{ name: "Cấu hình danh mục", href: "/admin/header-config", icon: Layers },
{ name: "Quản lý bài viết", href: "/admin/news", icon: Newspaper },
{ name: "Quản lý tag tìm kiếm", href: "/admin/tags", icon: Tags },
{ name: "Quản lý video", href: "/admin/videos", icon: Video },
// {
// name: 'Quản lý hội viên',
// icon: Users,
......@@ -62,25 +73,50 @@ const navigation: NavItem[] = [
// },
// ],
// },
{ name: 'Quản lý Email đăng ký', href: '/admin/contact-management/newsletter-emails', icon: Mail },
{ name: 'Quản lý ảnh', href: '/admin/media', icon: ImagePlus },
{
name: "Quản lý Email đăng ký",
href: "/admin/contact-management/newsletter-emails",
icon: Mail,
},
{ name: "Quản lý ảnh", href: "/admin/media", icon: ImagePlus },
];
const membersReservedSegments = new Set(['fields', 'regions']);
const membersReservedSegments = new Set(["fields", "regions"]);
export function AdminSidebar() {
const pathname = usePathname();
const { close, isOpen } = useSidebarStore();
const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({});
const [expandedGroups, setExpandedGroups] = React.useState<
Record<string, boolean>
>({});
const { data: logoData } = useGetLogo(
{
page: 1,
pageSize: 1,
sortField: "updated_at",
sortOrder: "desc",
},
{
query: {
select: (response: any) => {
const responseData = response?.responseData ?? response?.data?.responseData;
return (responseData?.rows?.[0] as Logo | undefined) ?? null;
},
},
}
);
const isItemActive = React.useCallback(
(href: string) => {
if (href === '/admin/members') {
if (href === "/admin/members") {
if (pathname === href) return true;
if (!pathname.startsWith(`${href}/`)) return false;
const nextSegment = pathname.slice(`${href}/`.length).split('/')[0];
return Boolean(nextSegment) && !membersReservedSegments.has(nextSegment);
const nextSegment = pathname.slice(`${href}/`.length).split("/")[0];
return (
Boolean(nextSegment) && !membersReservedSegments.has(nextSegment)
);
}
return pathname === href || pathname.startsWith(`${href}/`);
......@@ -88,7 +124,8 @@ export function AdminSidebar() {
[pathname],
);
const isGroupActive = (children: NavChild[]) => children.some((child) => isItemActive(child.href));
const isGroupActive = (children: NavChild[]) =>
children.some((child) => isItemActive(child.href));
const toggleGroup = (name: string) =>
setExpandedGroups((previous) => ({ ...previous, [name]: !previous[name] }));
......@@ -100,32 +137,43 @@ export function AdminSidebar() {
return (
<aside
className={cn(
'fixed left-0 top-0 z-40 h-dvh border-r border-[#063e8e]/10 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition-all duration-300',
isOpen ? 'w-72 translate-x-0 lg:w-72' : '-translate-x-full lg:w-24 lg:translate-x-0',
"fixed left-0 top-0 z-40 h-dvh border-r border-[#063e8e]/10 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition-all duration-300",
isOpen
? "w-72 translate-x-0 lg:w-72"
: "-translate-x-full lg:w-24 lg:translate-x-0",
)}
>
<div className="flex h-full flex-col">
<div className={cn('px-4 pb-4 pt-5', !isOpen && 'px-3')}>
<div className={cn("px-4 pb-4 pt-5", !isOpen && "px-3")}>
<Link
href="/admin/base-config"
onClick={handleMobileNavigate}
className={cn(
'flex items-center backdrop-blur-sm',
"flex items-center backdrop-blur-sm",
isOpen
? 'gap-4 rounded-[28px] border border-white/80 bg-white/95 px-4 py-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]'
: 'justify-center px-0 py-4',
? "gap-4 rounded-[28px] border border-white/80 bg-white/95 px-4 py-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]"
: "justify-center px-0 py-4",
)}
>
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] shadow-sm">
<Image src={logo} alt="VCCI HCM" className="h-10 w-10 object-contain" priority />
<Image
src={logoData?.logo_url ? resolveUploadUrl(logoData.logo_url) : logo}
alt={logoData?.logo_name || "VCCI HCM"}
width={40}
height={40}
className="h-10 w-10 object-contain"
priority
/>
</div>
{isOpen ? (
<div className="min-w-0">
<div className="truncate text-[13px] font-bold uppercase tracking-[0.22em] text-[#063e8e]">
VCCI News
{logoData?.logo_name || "VCCI News"}
</div>
<div className="mt-1 text-sm leading-5 text-slate-600">
Trang quản trị website
</div>
<div className="mt-1 text-sm leading-5 text-slate-600">Trang quản trị website</div>
</div>
) : null}
</Link>
......@@ -142,8 +190,8 @@ export function AdminSidebar() {
<nav
className={cn(
'scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2',
!isOpen && 'px-3',
"scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2",
!isOpen && "px-3",
)}
>
{navigation.map((item) => {
......@@ -155,8 +203,10 @@ export function AdminSidebar() {
<div
key={item.name}
className={cn(
'rounded-[26px] border border-transparent transition-all duration-200',
isOpen && expanded && 'border-[#063e8e]/10 bg-white/70 p-2 shadow-sm',
"rounded-[26px] border border-transparent transition-all duration-200",
isOpen &&
expanded &&
"border-[#063e8e]/10 bg-white/70 p-2 shadow-sm",
)}
>
<button
......@@ -164,21 +214,25 @@ export function AdminSidebar() {
onClick={() => isOpen && toggleGroup(item.name)}
title={!isOpen ? item.name : undefined}
className={cn(
'flex w-full items-center rounded-2xl text-sm font-medium transition-all duration-200',
"flex w-full items-center rounded-2xl text-sm font-medium transition-all duration-200",
active
? 'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]'
: 'text-slate-700 hover:bg-white/85 hover:text-[#063e8e]',
isOpen ? 'gap-3 px-4 py-3.5' : 'mx-auto h-14 w-14 justify-center p-0',
? "bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
: "text-slate-700 hover:bg-white/85 hover:text-[#063e8e]",
isOpen
? "gap-3 px-4 py-3.5"
: "mx-auto h-14 w-14 justify-center p-0",
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{isOpen ? (
<>
<span className="min-w-0 flex-1 text-left">{item.name}</span>
<span className="min-w-0 flex-1 text-left">
{item.name}
</span>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 transition-transform',
expanded && 'rotate-180',
"h-4 w-4 shrink-0 transition-transform",
expanded && "rotate-180",
)}
/>
</>
......@@ -196,10 +250,10 @@ export function AdminSidebar() {
href={child.href}
onClick={handleMobileNavigate}
className={cn(
'group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all',
"group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all",
childActive
? 'bg-[#dbe8ff] font-semibold text-[#063e8e]'
: 'text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]',
? "bg-[#dbe8ff] font-semibold text-[#063e8e]"
: "text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]",
)}
>
<span className="block">{child.name}</span>
......@@ -217,19 +271,23 @@ export function AdminSidebar() {
return (
<Link
key={item.name}
href={item.href || '#'}
href={item.href || "#"}
onClick={handleMobileNavigate}
title={!isOpen ? item.name : undefined}
className={cn(
'flex items-center rounded-2xl text-sm font-medium transition-all duration-200',
"flex items-center rounded-2xl text-sm font-medium transition-all duration-200",
active
? 'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]'
: 'text-slate-700 hover:bg-white/85 hover:text-[#063e8e]',
isOpen ? 'gap-3 px-4 py-3.5' : 'mx-auto h-14 w-14 justify-center p-0',
? "bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
: "text-slate-700 hover:bg-white/85 hover:text-[#063e8e]",
isOpen
? "gap-3 px-4 py-3.5"
: "mx-auto h-14 w-14 justify-center p-0",
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{isOpen ? <span className="min-w-0 flex-1">{item.name}</span> : null}
{isOpen ? (
<span className="min-w-0 flex-1">{item.name}</span>
) : null}
</Link>
);
})}
......@@ -248,7 +306,9 @@ export function AdminSidebar() {
</div>
<div>
<div>Về trang chủ</div>
<div className="mt-0.5 text-xs font-medium text-slate-500">Website công khai</div>
<div className="mt-0.5 text-xs font-medium text-slate-500">
Website công khai
</div>
</div>
</Link>
<div className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment