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