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]"
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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