Commit f25d62b4 authored by Phạm Quang Bảo's avatar Phạm Quang Bảo

Merge branch 'refactor/home_page' into 'develop-news'

fix: call api for post and optmize with ssr

See merge request !147
parents 0c34b7a8 74e3a4db
/**
* Raw banner row trả về từ `GET /api/v1.0/banner`.
*/
export type RawBanner = {
id: string;
file_id?: string | null;
banner_name?: string | null;
image_url?: string | null;
display_order?: number | null;
};
/**
* Raw file trả về từ `GET /api/v1.0/file/{id}`.
*/
export type RawFile = {
path?: string | null;
};
/**
* Shape chung của response phân trang: `{ responseData: { rows: [...] } }`.
*/
export type PagedResult<T> = {
responseData?: {
rows?: T[];
};
};
export type RawPostCategory = {
id?: string | null;
name?: string | null;
slug?: string | null;
url?: string | null;
type?: string | null;
};
export type RawPostThumbnail = {
path?: string | null;
original?: string | null;
};
export type RawPostContentStructure = {
post_content?: Array<{
content?: string | null;
}> | null;
};
/** Post cơ bản (title, slug, dates, categories). */
export type RawPost = {
id?: string | null;
title?: string | null;
slug?: string | null;
published_at?: string | null;
release_at?: string | null;
created_at?: string | null;
categories?: RawPostCategory[] | null;
};
/** Post kèm thumbnail (featured-news, member-connection). */
export type RawPostWithThumbnail = RawPost & {
thumbnail?: RawPostThumbnail | null;
};
/** Post kèm nội dung (summary/content/content_structure). */
export type RawPostWithContent = RawPost & {
summary?: string | null;
content?: string | null;
content_structure?: RawPostContentStructure | null;
};
/** Post sự kiện (kèm thumbnail + started_at). */
export type RawEventPost = RawPostWithThumbnail &
RawPostWithContent & {
started_at?: string | null;
};
/** Post sự kiện đầy đủ cho lịch sự kiện (started/ended/registration/location...). */
export type RawEventCalendarPost = RawEventPost & {
ended_at?: string | null;
registration_deadline?: string | null;
location?: string | null;
participation_fee?: string | null;
expired_at?: string | null;
is_featured?: boolean | null;
is_hidden?: boolean | null;
is_active?: boolean | null;
status?: string | null;
type?: string | null;
event_dates?: string[] | null;
};
......@@ -9,3 +9,9 @@ export interface VideoFormValues {
name: string;
url: string;
}
export type RawVideo = {
id?: string | null;
name?: string | null;
url?: string | null;
};
'use client';
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import links from "@/links";
import { getApiV10AdvertisementPublic } from "@/api/vcci-news/endpoints/advertisement";
import type { Advertisement } from "@/api/vcci-news/models/advertisement";
import links from "@/links";
import { getFallbackImage } from "@/lib/utils/fallback-image";
const FALLBACK_HREF = links.externalApiOrigin;
/** Lấy danh sách quảng cáo square từ API public (BE đã lọc ACTIVE, sort sort_order ASC). */
const fetchSquareAds = async (): Promise<Advertisement[]> => {
try {
const response = await getApiV10AdvertisementPublic({
type: "square",
limit: 20,
});
return (
(response as unknown as { responseData?: Advertisement[] } | undefined)
?.responseData ?? []
);
} catch (error) {
console.warn("[Advertisements] CMS unavailable, using fallback", error);
return [];
}
};
function AdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: string }) {
const src = item.file?.path ? links.resolveImageUrl(item.file.path) : fallbackSrc;
......@@ -56,14 +70,11 @@ function FallbackAdItem({ src }: { src: string }) {
);
}
function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIndex?: number }) {
const ads = useAdvertisements("square");
export async function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIndex?: number }) {
const ads = await fetchSquareAds();
const visibleAds = ads.slice(startIndex, startIndex + count);
const fallbackSrcs = useMemo(
() => Array.from({ length: count }, (_, i) => getFallbackImage(i)),
[count],
);
const fallbackSrcs = Array.from({ length: count }, (_, i) => getFallbackImage(i));
// Luôn render đủ `count` khung hình: vị trí nào API không có data thì dùng random fallback.
const items = Array.from({ length: count }, (_, i) => {
......@@ -79,5 +90,3 @@ function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIn
</aside>
);
}
\ No newline at end of file
export default Advertisements;
import Image from "next/image";
export type BannerItemData = {
id: string;
alt: string;
src: string;
};
export function BannerItem({ src, alt }: BannerItemData) {
return (
<Image
src={src}
alt={alt}
width={2560}
height={720}
sizes="100vw"
className="h-[220px] w-full object-cover sm:h-[320px] md:h-[430px] lg:h-[540px]"
/>
);
}
"use client";
import Image from "next/image";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay } from "swiper/modules";
import { Swiper as SwiperType } from "swiper/types";
import { useQuery } from "@tanstack/react-query";
import { useRef } from "react";
import "swiper/css";
import { useGetApiV10Banner } from "@/api/vcci-news/endpoints/banner";
import { getApiV10Banner } from "@/api/vcci-news/endpoints/banner";
import type { RawBanner } from "@/api/vcci-news/types/banner";
import type { RawFile } from "@/api/vcci-news/types/file";
import { getApiV10FileId } from "@/api/vcci-news/endpoints/file";
import { resolveCmsFileUrl } from "@/lib/utils/file";
import { useGetApiV10FileId } from "@/api/vcci-news/endpoints/file";
import { Skeleton } from "@/components/ui/skeleton";
import { BannerItem, type BannerItemData } from "./components/banner-item";
import "swiper/css";
type ApiEnvelope<T> = {
responseData?: T;
data?: {
responseData?: T;
};
};
const getEnvelopeData = <T,>(payload?: ApiEnvelope<T>) =>
payload?.responseData ?? payload?.data?.responseData;
type BannerRow = RawBanner;
type BannerRow = {
id: string;
file_id?: string | null;
banner_name?: string | null;
image_url?: string | null;
display_order?: number | null;
};
const FALLBACK_IMAGE = "/thumbnail.png";
/**
* Mock banner khi BE /api/banner lỗi hoặc không trả data.
* Dùng ảnh `thumbnail.png` đã có sẵn trong public/.
*/
const MOCK_BANNER_ROWS: BannerRow[] = [
{
id: "mock-banner-1",
......@@ -55,83 +39,58 @@ const MOCK_BANNER_ROWS: BannerRow[] = [
},
];
function BannerSlideItem({
src,
alt,
fileId,
}: {
src: string;
alt: string;
fileId?: string | null;
}) {
const { data: file, isPending } = useGetApiV10FileId(fileId!, {
query: {
enabled: !!fileId,
select: (response) => response?.responseData ?? null,
},
});
const resolveFileSrc = async (fileId?: string | null): Promise<string | null> => {
if (!fileId) return null;
if (isPending) {
return (
<Skeleton className="w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px]" />
);
try {
const response = await getApiV10FileId(fileId);
const file = response?.responseData as RawFile | undefined;
return file?.path ? resolveCmsFileUrl(file.path) : null;
} catch {
return null;
}
};
const url = src
? src
: file
? resolveCmsFileUrl(file.path)
: "/thumbnail.png";
return (
<Image
src={url}
alt={alt}
width={2560}
height={720}
sizes="100vw"
className="w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px] object-cover"
/>
const toBannerItems = async (rows: BannerRow[]): Promise<BannerItemData[]> =>
Promise.all(
rows.map(async (row) => ({
id: row.id,
alt: row.banner_name || "Banner",
src:
row.image_url ??
(await resolveFileSrc(row.file_id)) ??
FALLBACK_IMAGE,
})),
);
}
const Banner = () => {
const swiperRef = useRef<SwiperType | null>(null);
const { data: bannerData, isPending, isError } = useGetApiV10Banner(
{
const fetchBannerItems = async (): Promise<BannerItemData[]> => {
try {
const response = await getApiV10Banner({
filters: "status@=ACTIVE",
sortField: "display_order",
sortOrder: "asc",
},
{
query: {
staleTime: 60 * 1000,
},
},
);
const pageData = bannerData
? getEnvelopeData<{ rows?: BannerRow[] }>(bannerData as unknown as ApiEnvelope<{ rows?: BannerRow[] }>)
: undefined;
// BE trả rows rỗng HOẶC bị lỗi → fallback mock banner để UI không trống.
const rows: BannerRow[] =
pageData?.rows && pageData.rows.length > 0
? pageData.rows
: MOCK_BANNER_ROWS;
});
if (isPending && !rows.length) {
return (
<div className="w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px] bg-slate-100 flex items-center justify-center">
<Skeleton className="w-full h-full" />
</div>
);
const rows = (response?.responseData?.rows ?? []) as BannerRow[];
return await toBannerItems(rows.length > 0 ? rows : MOCK_BANNER_ROWS);
} catch {
return await toBannerItems(MOCK_BANNER_ROWS);
}
};
export function Banner() {
const swiperRef = useRef<SwiperType | null>(null);
if (!rows || rows.length === 0) {
const { data: items = [], isPending } = useQuery({
queryKey: ["banner-items", "status-active"] as const,
queryFn: fetchBannerItems,
staleTime: 60 * 1000,
});
if (isPending) {
return (
<div className="w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px] bg-slate-100 flex items-center justify-center">
<Skeleton className="w-full h-full" />
<div className="flex h-[220px] w-full items-center justify-center bg-slate-100 sm:h-[320px] md:h-[430px] lg:h-[540px]">
<Skeleton className="h-full w-full" />
</div>
);
}
......@@ -140,25 +99,16 @@ const Banner = () => {
<Swiper
modules={[Autoplay]}
autoplay={{ delay: 4000, disableOnInteraction: false }}
loop={rows.length > 1}
loop={items.length > 1}
slidesPerView={1}
onSwiper={(s) => (swiperRef.current = s)}
className="w-full overflow-hidden"
>
{rows.map((row) => {
const src = row.image_url ?? (row.file_id ? undefined : "/thumbnail.png");
return (
<SwiperSlide key={row.id}>
<BannerSlideItem
src={src ?? ""}
alt={row.banner_name || "Banner"}
fileId={row.file_id ?? null}
/>
{items.map((item) => (
<SwiperSlide key={item.id}>
<BannerItem {...item} />
</SwiperSlide>
);
})}
))}
</Swiper>
);
};
export default Banner;
}
'use client';
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import { getApiV10Post } from "@/api/vcci-news/endpoints/post";
import type { RawPost } from "@/api/vcci-news/types/post-raw";
import type { PagedResult } from "@/api/vcci-news/types/paged-result";
import {
buildPostLink,
normalizeLink,
} from "@/lib/utils/post";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
function BusinessOpportunities() {
const { businessPosts, categoryLinks, categoryNames } = useHomePosts();
const listSlots = Array.from({ length: 4 }, (_, index) => businessPosts[index] ?? null);
const sectionLink =
categoryLinks.get(categoryNames.coHoiKinhDoanh.toLowerCase()) ??
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh";
const CATEGORY_ID = "0a460499-89c1-4f52-8592-1fb7bb69c4a2";
const SECTION_LINK_FALLBACK = "/xuc-tien-thuong-mai/co-hoi-kinh-doanh";
const SECTION_PARAMS = {
page: 1,
pageSize: 4,
sortField: "created_at",
sortOrder: "desc",
priorityFeatured: false,
filters: [
`category.id==${CATEGORY_ID}`,
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
} as const;
/** Item đã chuẩn hóa mà component cần để render. */
type BusinessOpportunityItem = {
id: string;
title: string;
externalLink: string;
createdAt: string;
publishedAt: string;
};
const toBusinessOpportunityItem = (post: RawPost): BusinessOpportunityItem => {
const title = String(post.title ?? "").trim();
return {
id: String(post.id ?? ""),
title,
externalLink: buildPostLink(
post.slug ? `/${post.slug}` : undefined,
post.id ? String(post.id) : "",
"#",
),
createdAt: String(post.created_at ?? ""),
publishedAt: String(post.published_at ?? post.release_at ?? post.created_at ?? ""),
};
};
/** Đọc URL category "Cơ hội kinh doanh" từ bài viết đầu tiên (nếu có). */
const resolveSectionLink = (posts: RawPost[]) => {
const categoryUrl = posts[0]?.categories?.find(
(category) => category?.url && category.url !== "#",
)?.url;
return categoryUrl ? normalizeLink(categoryUrl) : SECTION_LINK_FALLBACK;
};
const fetchBusinessOpportunityPosts = async (): Promise<{
posts: BusinessOpportunityItem[];
sectionLink: string;
}> => {
try {
const response = await getApiV10Post(SECTION_PARAMS);
const rows =
(response as PagedResult<RawPost> | undefined)?.responseData?.rows ?? [];
if (rows.length === 0) throw new Error("Empty rows");
return {
posts: rows.map(toBusinessOpportunityItem),
sectionLink: resolveSectionLink(rows),
};
} catch (error) {
// eslint-disable-next-line no-console
console.warn(
"[BusinessOpportunities] CMS unavailable, falling back to mock data",
error,
);
const mockPosts = MOCK_HOME_POSTS.filter((item) =>
item.categories.some((category) => category.id === CATEGORY_ID),
);
return {
posts: mockPosts.map((item) => ({
id: item.id,
title: item.title,
externalLink: item.externalLink,
createdAt: item.createdAt,
publishedAt: item.publishedAt,
})),
sectionLink:
mockPosts[0]?.categories.find(
(category) => category.id === CATEGORY_ID && category.url !== "#",
)?.url ?? SECTION_LINK_FALLBACK,
};
}
};
export function BusinessOpportunitiesSkeleton() {
return (
<section className="flex flex-1 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
......@@ -21,6 +113,40 @@ function BusinessOpportunities() {
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
<ChevronRight className="h-5 w-5 text-[#24469c]" />
</div>
<div className="flex min-h-[270px] flex-1 flex-col gap-2.5">
{Array.from({ length: 4 }, (_, index) => (
<div
key={`business-skeleton-${index}`}
className={`flex min-h-[58px] gap-3 rounded-[16px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 animate-pulse rounded bg-[#eef3fb]" />
<div className="mt-1.5 h-4 w-24 animate-pulse rounded bg-[#f4f7fb]" />
</div>
</div>
))}
</div>
</section>
);
}
export async function BusinessOpportunities() {
const { posts, sectionLink } = await fetchBusinessOpportunityPosts();
const listSlots = Array.from({ length: 4 }, (_, index) => posts[index] ?? null);
return (
<section className="flex flex-1 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Cơ hội kinh doanh
</h2>
<div className="mt-2.5 h-1 w-10 rounded-full bg-[#f7b500]" />
</div>
<Link
href={sectionLink}
......@@ -76,5 +202,3 @@ function BusinessOpportunities() {
</section>
);
}
\ No newline at end of file
export default BusinessOpportunities;
"use client";
import { getOrganizations } from "@/api/vcci-hcm/endpoints/organizations";
import type { Organization } from "@/api/vcci-hcm/models";
import memberImages from "@/constants/memberImages";
import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/mockdata/bff-fallback";
import { ChevronRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Autoplay } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
const VCCI_HCM_SITE_URL = "https://vccihcm.vn";
const FEATURED_MEMBER_MORE_URL =
`${VCCI_HCM_SITE_URL}/giao-thuong-b2b?filters=users.status_id+%3D%3D+36ca1cc5-7b6e-4f9f-b973-69c5207deb62&sortField=created_at&sortOrder=ASC`;
const MOCK_FEATURED_MEMBER_ROWS =
MOCK_FEATURED_MEMBERS_RESPONSE.responseData?.rows as unknown as Organization[] ?? [];
const resolveMemberImage = (avatar: string | null | undefined, index: number) => {
if (avatar?.startsWith("http://") || avatar?.startsWith("https://")) {
return avatar;
}
if (avatar?.startsWith("/")) {
return `${VCCI_HCM_SITE_URL}${avatar}`;
}
return memberImages[index % memberImages.length] ?? "/img-error.png";
};
const getMemberDetailUrl = (orgLink: string | null | undefined) => {
if (orgLink) {
return `${VCCI_HCM_SITE_URL}/giao-thuong-b2b/doanh-nghiep/${orgLink}`;
}
return null;
};
/** Khối "Hội viên tiêu biểu": dữ liệu từ Organizations API, bắt buộc client vì Swiper autoplay. */
export function FeaturedMembers() {
const { data: organizationsResponse, isLoading: featuredMembersLoading } =
useQuery<Organization[] | undefined>({
queryKey: [
"featured-members",
"status-36ca1cc5-7b6e-4f9f-b973-69c5207deb62",
] as const,
queryFn: async () => {
const response = await getOrganizations({
filters: "users.status_id==36ca1cc5-7b6e-4f9f-b973-69c5207deb62",
pageSize: "12",
sortField: "created_at",
sortOrder: "DESC",
});
const rows = ((response as any)?.responseData?.rows ?? []) as Organization[];
return rows.length > 0 ? rows : MOCK_FEATURED_MEMBER_ROWS;
},
staleTime: 60 * 1000,
});
const featuredMembers = organizationsResponse ?? MOCK_FEATURED_MEMBER_ROWS;
const displayMembers = featuredMembers.slice(0, 9);
return (
<aside className="flex-1 rounded-[16px] bg-[#f7b500] p-4 shadow-[0_18px_34px_rgba(247,181,0,0.18)] md:p-5">
<div className="flex items-center justify-between gap-3 pb-10">
<div>
<h2 className="client-section-title uppercase text-[#20449a]">
Hội viên tiêu biểu
</h2>
<div className="mt-2.5 h-1 w-10 rounded-full bg-white" />
</div>
<Link
href={FEATURED_MEMBER_MORE_URL}
target="_blank"
rel="noreferrer"
className="text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
<ChevronRight className="h-5 w-5" />
</Link>
</div>
{featuredMembersLoading ? (
<div className="rounded-[14px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70">
Đang tải dữ liệu...
</div>
) : displayMembers.length === 0 ? (
<div className="rounded-[14px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70">
Chưa có thông tin.
</div>
) : (
<Swiper
modules={[Autoplay]}
autoplay={{ delay: 4200, disableOnInteraction: false }}
observer
observeParents
updateOnWindowResize
slidesPerView="auto"
spaceBetween={16}
className="w-full"
>
{displayMembers.map((member, index) => {
const detailUrl = getMemberDetailUrl(member.org_link);
return (
<SwiperSlide
key={member.id}
className="!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
<article className="rounded-[14px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]">
{detailUrl ? (
<a
href={detailUrl}
target="_blank"
rel="noreferrer"
className="block"
>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<Image
src={resolveMemberImage(member.avatar, index)}
alt={member.name ?? ""}
width={260}
height={180}
className="h-[180px] w-[260px] max-w-full object-contain"
/>
</div>
</div>
<h3 className="mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e] transition-colors hover:text-[#20449a]">
{member.name}
</h3>
</a>
) : (
<>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<Image
src={resolveMemberImage(member.avatar, index)}
alt={member.name ?? ""}
width={260}
height={180}
className="h-[180px] w-[260px] max-w-full object-contain"
/>
</div>
</div>
<h3 className="mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e]">
{member.name}
</h3>
</>
)}
</article>
</SwiperSlide>
);
})}
</Swiper>
)}
</aside>
);
}
'use client';
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import { getApiV10Post } from "@/api/vcci-news/endpoints/post";
import type {
RawPostWithThumbnail,
} from "@/api/vcci-news/types/post-raw";
import type { PagedResult } from "@/api/vcci-news/types/paged-result";
import {
buildPostLink,
normalizeLink,
resolveAssetUrl,
} from "@/lib/utils/post";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";
import HorizontalAdBanner from "@/app/(main)/(home)/components/horizontal-ad-banner";
import { HorizontalAdBanner } from "@/app/(main)/(home)/components/horizontal-ad-banner";
import { getFallbackImage } from "@/lib/utils/fallback-image";
const FALLBACK_CATEGORY_LINK = "/hoat-dong/tin-tuc";
const TIN_VCCI_CATEGORY_ID = "b89b2ba6-a699-47cb-87e4-0643aea549a9";
const SECTION_LINK_FALLBACK = "/hoat-dong/tin-tuc";
const PAGE_SIZE = 3;
/** Featured posts: is_featured==true, ưu tiên bài featured, sort created_at desc ở BE. */
const SECTION_PARAMS = {
page: 1,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
priorityFeatured: true,
filters: ["is_featured==true", "is_hidden==false", "is_active==true", "type==news"].join(","),
} as const;
type FeaturedNewsItem = {
id: string;
title: string;
externalLink: string;
createdAt: string;
publishedAt: string;
categoryName: string;
thumbnail: { url: string; alt: string } | null;
};
const toFeaturedNewsItem = (post: RawPostWithThumbnail): FeaturedNewsItem => {
const title = String(post.title ?? "").trim();
const thumbnailPath = post.thumbnail?.path ?? post.thumbnail?.original ?? null;
return {
id: String(post.id ?? ""),
title,
externalLink: buildPostLink(
post.slug ? `/${post.slug}` : (title ? `/${title}` : undefined),
post.id ? String(post.id) : "",
),
createdAt: String(post.created_at ?? ""),
publishedAt: String(post.published_at ?? post.release_at ?? post.created_at ?? ""),
categoryName: String(post.categories?.[0]?.name ?? ""),
thumbnail: thumbnailPath
? { url: resolveAssetUrl(thumbnailPath), alt: title }
: null,
};
};
/** Link danh mục Tin VCCI lấy từ chính danh sách posts, fallback nếu không có. */
const resolveSectionLink = (posts: RawPostWithThumbnail[]) => {
for (const post of posts) {
const category = post.categories?.find(
(item) => item?.id === TIN_VCCI_CATEGORY_ID && item?.url && item.url !== "#",
);
if (category?.url) return normalizeLink(category.url, SECTION_LINK_FALLBACK);
}
return SECTION_LINK_FALLBACK;
};
const fetchFeaturedNewsPosts = async (): Promise<{
posts: FeaturedNewsItem[];
sectionLink: string;
}> => {
try {
const response = await getApiV10Post(SECTION_PARAMS);
const rows =
(response as PagedResult<RawPostWithThumbnail> | undefined)?.responseData?.rows ?? [];
if (rows.length === 0) throw new Error("Empty rows");
return {
posts: rows.map(toFeaturedNewsItem),
sectionLink: resolveSectionLink(rows),
};
} catch (error) {
console.warn("[FeaturedNews] CMS unavailable, falling back to mock data", error);
const mockPosts = MOCK_HOME_POSTS.filter(
(item) => item.isFeatured && !item.isHidden,
).slice(0, PAGE_SIZE);
const mockSectionLink =
mockPosts
.flatMap((item) => item.categories)
.find((category) => category.id === TIN_VCCI_CATEGORY_ID && category.url !== "#")
?.url ?? SECTION_LINK_FALLBACK;
return {
posts: mockPosts.map((item) => ({
id: item.id,
title: item.title,
externalLink: item.externalLink,
createdAt: item.createdAt,
publishedAt: item.publishedAt,
categoryName: item.categories[0]?.name ?? "",
thumbnail: item.thumbnail,
})),
sectionLink: mockSectionLink,
};
}
};
export function FeaturedNewsSkeleton() {
const primaryFallback = getFallbackImage(0);
return (
<section className="py-8 md:py-10">
<div className="w-full">
<div className="mb-8 flex items-center justify-between gap-4">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Tin nổi bật
</h2>
<div className="mt-3 h-[5px] w-[68px] rounded-full bg-[#f7b500]" />
</div>
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<div className="relative overflow-hidden rounded-[16px] bg-[#e9eef8] shadow-[0_16px_32px_rgba(28,52,120,0.12)] md:rounded-[16px] md:min-h-[320px] lg:min-h-[380px]">
<div className="flex h-full min-h-[195px] flex-col justify-end p-3.5 md:min-h-80 md:p-5 lg:min-h-[380px]">
<span className="mb-2 h-8 w-28 animate-pulse rounded-[10px] bg-white/80" />
<div className="h-8 w-3/4 animate-pulse rounded bg-white/90 md:h-10" />
<div className="mt-2 h-5 w-28 animate-pulse rounded bg-white/70" />
</div>
</div>
function FeaturedNews() {
const { featuredPosts, categoryNames, categoryLinks } = useHomePosts();
const featuredNewsItems = featuredPosts.slice(0, 3);
<div className="flex flex-col gap-4 lg:min-h-[380px]">
<div className="grid flex-1 gap-4 md:grid-cols-2">
{Array.from({ length: 2 }, (_, index) => (
<div
key={`featured-news-skeleton-${index}`}
className="rounded-[16px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] min-h-[165px]"
>
<div className="flex h-full min-h-[165px] flex-col justify-end p-3.5">
<span className="mb-2 h-7 w-24 animate-pulse rounded-[10px] bg-white/80" />
<div className="h-6 w-5/6 animate-pulse rounded bg-white/90" />
<div className="mt-2 h-4 w-24 animate-pulse rounded bg-white/70" />
</div>
</div>
))}
</div>
<HorizontalAdBanner />
</div>
</div>
</div>
</section>
);
}
export async function FeaturedNews() {
const { posts, sectionLink } = await fetchFeaturedNewsPosts();
const featuredNewsItems = posts.slice(0, 3);
const [primaryItem, ...secondaryItems] = featuredNewsItems;
const secondarySlots = Array.from({ length: 2 }, (_, index) => secondaryItems[index] ?? null);
const featuredOverviewLink =
categoryLinks.get(categoryNames.tinVcci.toLowerCase()) ?? FALLBACK_CATEGORY_LINK;
const primaryFallback = useMemo(() => getFallbackImage(0), []);
const secondaryFallbacks = useMemo(
() => [getFallbackImage(1), getFallbackImage(2)],
[],
);
const primaryFallback = getFallbackImage(0);
const secondaryFallbacks = [getFallbackImage(1), getFallbackImage(2)];
return (
<section className="py-8 md:py-10">
......@@ -37,7 +182,7 @@ function FeaturedNews() {
</div>
<Link
href={featuredOverviewLink}
href={sectionLink}
className="text-[#2b56c0] transition-colors hover:text-[#173f9f]"
>
<ChevronRight className="h-5 w-5" />
......@@ -62,7 +207,7 @@ function FeaturedNews() {
<div className="relative flex h-full flex-col justify-end p-3.5 md:p-5">
<span className="mb-2 inline-flex w-fit rounded-[10px] bg-[#ffc400] px-3 py-1 text-sm font-bold text-[#1d3f90]">
{primaryItem.categories[0]?.name || "Tin nổi bật"}
{primaryItem.categoryName || "Tin nổi bật"}
</span>
<h3 className="max-w-3xl line-clamp-2 text-[16px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500] md:line-clamp-3 md:text-[22px] lg:text-[24px]">
......@@ -108,7 +253,7 @@ function FeaturedNews() {
<div className="relative flex h-full flex-col justify-end p-3.5">
<span className="mb-2 inline-flex w-fit rounded-[10px] bg-[#ffc400] px-3 py-1 text-sm font-bold text-[#1d3f90]">
{item.categories[0]?.name || "Tin nổi bật"}
{item.categoryName || "Tin nổi bật"}
</span>
<h4 className="line-clamp-2 text-[16px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500] md:text-[17px]">
......@@ -145,5 +290,3 @@ function FeaturedNews() {
</section>
);
}
export default FeaturedNews;
'use client';
import Image from "next/image";
import Link from "next/link";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import { getApiV10AdvertisementPublic } from "@/api/vcci-news/endpoints/advertisement";
import type { Advertisement } from "@/api/vcci-news/models/advertisement";
import links from "@/links";
const FALLBACK_HREF = links.externalApiOrigin;
const FALLBACK_SRC = "/quang-cao/qc-1.jpg";
function HorizontalAdBanner() {
const ads = useAdvertisements("horizontal");
const ad = ads[0];
/** Lấy 1 quảng cáo horizontal từ API public (BE đã lọc ACTIVE, sort sort_order ASC). */
const fetchHorizontalAd = async (): Promise<Advertisement | null> => {
try {
const response = await getApiV10AdvertisementPublic({
type: "horizontal",
limit: 1,
});
const rows =
(response as unknown as { responseData?: Advertisement[] } | undefined)
?.responseData ?? [];
return rows[0] ?? null;
} catch (error) {
console.warn("[HorizontalAdBanner] CMS unavailable, using fallback", error);
return null;
}
};
export async function HorizontalAdBanner() {
const ad = await fetchHorizontalAd();
const href = ad?.link || FALLBACK_HREF;
const src = ad?.file?.path ? links.resolveImageUrl(ad.file.path) : FALLBACK_SRC;
......@@ -37,5 +52,3 @@ function HorizontalAdBanner() {
</Link>
);
}
\ No newline at end of file
export default HorizontalAdBanner;
"use client";
import {
useGetApiV10Post,
} from "@/api/vcci-news/endpoints/post";
import type { RawPostWithThumbnail } from "@/api/vcci-news/types/post-raw";
import {
buildPostLink,
normalizeLink,
resolveAssetUrl,
} from "@/lib/utils/post";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay } from "swiper/modules";
const KET_NOI_HOI_VIEN_CATEGORY_ID = "a37b8a02-e8b3-42ce-9225-6dae460fed99";
const SECTION_LINK_FALLBACK = "/hoi-vien/ket-noi-hoi-vien";
const MEMBER_CONNECTION_FALLBACK_IMAGE = "/home/20-2048x1365.webp";
const PAGE_SIZE = 2;
/** Params lấy 2 bài "Kết nối hội viên" — sort & filter ở BE. */
const CONNECTION_POSTS_PARAMS = {
page: 1,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
priorityFeatured: false,
filters: [
`category.id==${KET_NOI_HOI_VIEN_CATEGORY_ID}`,
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
} as const;
type RawConnectionPost = RawPostWithThumbnail & {
};
export type MemberConnectionPost = {
id: string;
title: string;
externalLink: string;
createdAt: string;
publishedAt: string;
categoryName: string;
thumbnail: { url: string; alt: string } | null;
};
const toConnectionPost = (post: RawConnectionPost): MemberConnectionPost => {
const title = String(post.title ?? "").trim();
const thumbnailPath = post.thumbnail?.path ?? post.thumbnail?.original ?? null;
return {
id: String(post.id ?? ""),
title,
externalLink: buildPostLink(
post.slug ? `/${post.slug}` : (title ? `/${title}` : undefined),
post.id ? String(post.id) : "",
),
createdAt: String(post.created_at ?? ""),
publishedAt: String(post.published_at ?? post.release_at ?? post.created_at ?? ""),
categoryName: String(post.categories?.[0]?.name ?? ""),
thumbnail: thumbnailPath
? { url: resolveAssetUrl(thumbnailPath), alt: title }
: null,
};
};
/** Link danh mục Kết nối hội viên từ chính posts, fallback nếu không có. */
const resolveSectionLink = (posts: RawConnectionPost[]) => {
for (const post of posts) {
const category = post.categories?.find(
(item) => item?.id === KET_NOI_HOI_VIEN_CATEGORY_ID && item?.url && item.url !== "#",
);
if (category?.url) return normalizeLink(category.url, SECTION_LINK_FALLBACK);
}
return SECTION_LINK_FALLBACK;
};
const toMockConnectionPosts = (): MemberConnectionPost[] =>
MOCK_HOME_POSTS.filter((item) =>
item.categories.some((category) => category.id === KET_NOI_HOI_VIEN_CATEGORY_ID),
)
.slice(0, PAGE_SIZE)
.map((item) => ({
id: item.id,
title: item.title,
externalLink: item.externalLink,
createdAt: item.createdAt,
publishedAt: item.publishedAt,
categoryName: item.categories[0]?.name ?? "",
thumbnail: item.thumbnail,
}));
const toConnectionPostsResult = (response: unknown): {
posts: MemberConnectionPost[];
sectionLink: string;
} => {
const rows =
(response as { responseData?: { rows?: RawConnectionPost[] } } | undefined)
?.responseData?.rows ?? [];
if (rows.length === 0) {
return {
posts: toMockConnectionPosts(),
sectionLink: SECTION_LINK_FALLBACK,
};
}
return {
posts: rows.map(toConnectionPost),
sectionLink: resolveSectionLink(rows),
};
};
const renderConnectionCard = (item: MemberConnectionPost, aspectClass = "aspect-[16/10] overflow-hidden") => (
<>
<div className={aspectClass}>
<Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title}
width={520}
height={420}
className="h-full w-full object-cover object-[center_80%]"
/>
</div>
<div className="absolute inset-0 bg-linear-to-t from-[#0d2f5f]/85 via-[#0d2f5f]/30 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4">
<h4 className="line-clamp-2 text-[15px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500]">
{item.title}
</h4>
<div className="mt-1.5 flex items-center gap-2">
{item.categoryName && (
<>
<span className="text-[12px] font-medium text-[#f7b500]">
{item.categoryName}
</span>
<span className="text-[12px] text-white/50">•</span>
</>
)}
<p className="text-[12px] font-medium text-white/78">
{dayjs(item.publishedAt || item.createdAt).format("DD/MM/YYYY")}
</p>
</div>
</div>
</>
);
const CONNECTION_CARD_LINK_CLASS =
"group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]";
export function MemberConnectionSkeleton() {
return (
<aside className="w-full xl:w-[31%] xl:min-w-[320px]">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Kết nối hội viên
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 2 }, (_, index) => (
<div
key={`member-connection-skeleton-${index}`}
className="h-[220px] animate-pulse rounded-[14px] bg-[#dde5f3]"
/>
))}
</div>
</aside>
);
}
/** Khối "Kết nối hội viên": client component dùng hook orval (mobile dùng Swiper). */
export function MemberConnection() {
// Dùng hook orval useGetApiV10Post: queryKey tự sinh, retry, devtools sẵn.
const connectionPostsQuery = useGetApiV10Post(CONNECTION_POSTS_PARAMS, {
query: {
staleTime: 60 * 1000,
select: toConnectionPostsResult,
},
});
const connectionPosts = connectionPostsQuery.data?.posts ?? [];
const sectionLink = connectionPostsQuery.data?.sectionLink ?? SECTION_LINK_FALLBACK;
return (
<aside className="w-full xl:w-[31%] xl:min-w-[320px]">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Kết nối hội viên
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
<Link
href={sectionLink}
className="text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<ChevronRight className="h-5 w-5" />
</Link>
</div>
<div>
{connectionPosts.length > 0 ? (
<>
{/* Mobile + xl+: Swiper */}
<div className="md:hidden xl:block">
<Swiper
modules={[Autoplay]}
autoplay={{ delay: 4000, disableOnInteraction: false }}
loop={connectionPosts.length > 1}
slidesPerView={1}
className="w-full overflow-hidden rounded-[14px]"
>
{connectionPosts.map((item) => (
<SwiperSlide key={item.id}>
<Link href={item.externalLink} className={CONNECTION_CARD_LINK_CLASS}>
{renderConnectionCard(item, "aspect-[16/10] overflow-hidden xl:aspect-[1.25/1]")}
</Link>
</SwiperSlide>
))}
</Swiper>
</div>
{/* md to < xl: Grid 2 columns */}
<div className="hidden gap-4 md:grid md:grid-cols-2 xl:hidden">
{connectionPosts.map((item) => (
<Link key={item.id} href={item.externalLink} className={CONNECTION_CARD_LINK_CLASS}>
{renderConnectionCard(item, "aspect-[16/10] overflow-hidden")}
</Link>
))}
</div>
</>
) : (
<div className="rounded-[14px] bg-[#eef3fb] px-5 py-10 text-center text-sm text-[#7f8eab]">
Chưa có thông tin.
</div>
)}
</div>
</aside>
);
}
This diff is collapsed.
"use client";
import { getOrganizations } from "@/api/vcci-hcm/endpoints/organizations";
import type { Organization } from "@/api/vcci-hcm/models";
import partnerImages from "@/constants/partnerImages";
import { MOCK_PARTNERS_RESPONSE } from "@/mockdata/bff-fallback";
import Image from "next/image";
import { useQuery } from "@tanstack/react-query";
import { Autoplay } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
const VCCI_HCM_SITE_URL = "https://vccihcm.vn";
const resolvePartnerImage = (avatar: string | null | undefined, index: number) => {
if (avatar?.startsWith("http://") || avatar?.startsWith("https://")) {
return avatar;
}
if (avatar?.startsWith("/")) {
return `${VCCI_HCM_SITE_URL}${avatar}`;
}
return partnerImages[index % partnerImages.length] ?? "/img-error.png";
};
const MOCK_PARTNER_ROWS =
MOCK_PARTNERS_RESPONSE.responseData.rows as unknown as Organization[];
export function Partners() {
const { data: partnersResponse } = useQuery<Organization[] | undefined>({
queryKey: ["home-partners", "type-sponsor"] as const,
queryFn: async () => {
const response = await getOrganizations({
filters: "type==SPONSOR",
pageSize: "12",
sortField: "sort_order",
sortOrder: "ASC",
});
const rows = ((response as any)?.responseData?.rows ?? []) as Organization[];
return rows.length > 0 ? rows : MOCK_PARTNER_ROWS;
},
staleTime: 60 * 1000,
});
const partners = (partnersResponse ?? MOCK_PARTNER_ROWS).slice(0, 12);
return (
<aside className="flex w-full flex-col xl:w-[43%]">
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Đối tác
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
</div>
{partners.length > 0 ? (
<Swiper
modules={[Autoplay]}
autoplay={{ delay: 4200, disableOnInteraction: false }}
observer
observeParents
updateOnWindowResize
slidesPerView="auto"
spaceBetween={16}
className="w-full"
>
{partners.map((partner, index) => (
<SwiperSlide
key={partner.id}
className="!h-auto !w-full sm:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
{partner.website ? (
<a
href={partner.website}
target="_blank"
rel="noreferrer"
className="block"
>
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_24px_rgba(31,59,124,0.1)] xl:h-[151px]">
<Image
src={resolvePartnerImage(partner.avatar, index)}
alt={partner.name ?? ""}
width={140}
height={72}
className="max-h-full w-full object-contain"
/>
</div>
</a>
) : (
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] xl:h-[151px]">
<Image
src={resolvePartnerImage(partner.avatar, index)}
alt={partner.name ?? ""}
width={140}
height={72}
className="max-h-full w-full object-contain"
/>
</div>
)}
</SwiperSlide>
))}
</Swiper>
) : (
<div className="rounded-2xl border border-[#edf1f7] bg-white px-5 py-10 text-center text-sm text-gray-500">
Chưa có thông tin.
</div>
)}
</aside>
);
}
\ No newline at end of file
'use client';
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import { getApiV10Post } from "@/api/vcci-news/endpoints/post";
import type { RawPost } from "@/api/vcci-news/types/post-raw";
import type { PagedResult } from "@/api/vcci-news/types/paged-result";
import {
buildPostLink,
normalizeLink,
} from "@/lib/utils/post";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
function PolicyAndLaws() {
const { policyPosts, categoryLinks, categoryNames } = useHomePosts();
const listSlots = Array.from({ length: 4 }, (_, index) => policyPosts[index] ?? null);
const sectionLink =
categoryLinks.get(categoryNames.chinhSachPhapLuat.toLowerCase()) ??
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat";
const CATEGORY_ID = "cc448be9-b9ea-46a8-aa7b-0584803330e8";
const SECTION_LINK_FALLBACK = "/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat";
const SECTION_PARAMS = {
page: 1,
pageSize: 4,
sortField: "created_at",
sortOrder: "desc",
priorityFeatured: false,
filters: [
`category.id==${CATEGORY_ID}`,
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
} as const;
/** Item đã chuẩn hóa mà component cần để render. */
type PolicyAndLawsItem = {
id: string;
title: string;
externalLink: string;
createdAt: string;
publishedAt: string;
};
const toPolicyAndLawsItem = (post: RawPost): PolicyAndLawsItem => {
const title = String(post.title ?? "").trim();
return {
id: String(post.id ?? ""),
title,
externalLink: buildPostLink(
post.slug ? `/${post.slug}` : undefined,
post.id ? String(post.id) : "",
"#",
),
createdAt: String(post.created_at ?? ""),
publishedAt: String(post.published_at ?? post.release_at ?? post.created_at ?? ""),
};
};
/** Đọc URL category "Chính sách và Pháp luật" từ bài viết đầu tiên (nếu có). */
const resolveSectionLink = (posts: RawPost[]) => {
const categoryUrl = posts[0]?.categories?.find(
(category) => category?.url && category.url !== "#",
)?.url;
return categoryUrl ? normalizeLink(categoryUrl) : SECTION_LINK_FALLBACK;
};
const fetchPolicyAndLawsPosts = async (): Promise<{
posts: PolicyAndLawsItem[];
sectionLink: string;
}> => {
try {
const response = await getApiV10Post(SECTION_PARAMS);
const rows =
(response as PagedResult<RawPost> | undefined)?.responseData?.rows ?? [];
if (rows.length === 0) throw new Error("Empty rows");
return {
posts: rows.map(toPolicyAndLawsItem),
sectionLink: resolveSectionLink(rows),
};
} catch (error) {
// eslint-disable-next-line no-console
console.warn(
"[PolicyAndLaws] CMS unavailable, falling back to mock data",
error,
);
const mockPosts = MOCK_HOME_POSTS.filter((item) =>
item.categories.some((category) => category.id === CATEGORY_ID),
);
return {
posts: mockPosts.map((item) => ({
id: item.id,
title: item.title,
externalLink: item.externalLink,
createdAt: item.createdAt,
publishedAt: item.publishedAt,
})),
sectionLink:
mockPosts[0]?.categories.find(
(category) => category.id === CATEGORY_ID && category.url !== "#",
)?.url ?? SECTION_LINK_FALLBACK,
};
}
};
export function PolicyAndLawsSkeleton() {
return (
<section className="flex flex-1 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Chính sách & pháp luật
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
<ChevronRight className="h-5 w-5 text-[#24469c]" />
</div>
<div className="flex min-h-[270px] flex-1 flex-col gap-2.5">
{Array.from({ length: 4 }, (_, index) => (
<div
key={`policy-skeleton-${index}`}
className={`flex min-h-[58px] gap-3 rounded-[16px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 animate-pulse rounded bg-[#eef3fb]" />
<div className="mt-1.5 h-4 w-24 animate-pulse rounded bg-[#f4f7fb]" />
</div>
</div>
))}
</div>
</section>
);
}
export async function PolicyAndLaws() {
const { posts, sectionLink } = await fetchPolicyAndLawsPosts();
const listSlots = Array.from({ length: 4 }, (_, index) => posts[index] ?? null);
return (
<section className="flex flex-1 flex-col">
......@@ -76,5 +205,3 @@ function PolicyAndLaws() {
</section>
);
}
export default PolicyAndLaws;
"use client";
import Image from "next/image";
import partnerImages from "@/constants/partnerImages";
import { ChevronRight, Play } from "lucide-react";
import Link from "next/link";
import { useGetOrganizations } from "@/api/vcci-hcm/endpoints/organizations";
import type { Organization } from "@/api/vcci-hcm/models";
import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video";
import type { Video } from "@/api/vcci-news/models/video";
import { MOCK_PARTNERS_RESPONSE } from "@/mockdata/bff-fallback";
import { getVideoThumbnail, normalizeVideoUrl } from "@/lib/utils/video";
import { Autoplay } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
const VCCI_HCM_SITE_URL = "https://vccihcm.vn";
const resolvePartnerImage = (avatar: string | null | undefined, index: number) => {
if (avatar?.startsWith("http://") || avatar?.startsWith("https://")) {
return avatar;
}
if (avatar?.startsWith("/")) {
return `${VCCI_HCM_SITE_URL}${avatar}`;
}
return partnerImages[index % partnerImages.length] ?? "/img-error.png";
};
const MOCK_PARTNER_ROWS =
MOCK_PARTNERS_RESPONSE.responseData.rows as unknown as Organization[];
type ClientVideoItem = Video & {
thumbnail: string;
watchUrl: string;
};
const toClientVideo = (video: Video): ClientVideoItem => ({
...video,
thumbnail: getVideoThumbnail(video.url),
watchUrl: normalizeVideoUrl(video.url),
});
const renderPartnerContent = (partners: Organization[]) => {
if (partners.length > 0) {
return (
<Swiper
modules={[Autoplay]}
autoplay={{ delay: 4200, disableOnInteraction: false }}
observer
observeParents
updateOnWindowResize
slidesPerView="auto"
spaceBetween={16}
className="w-full"
>
{partners.map((partner, index) => (
<SwiperSlide
key={partner.id}
className="!h-auto !w-full sm:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
{partner.website ? (
<a
href={partner.website}
target="_blank"
rel="noreferrer"
className="block"
>
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_24px_rgba(31,59,124,0.1)] xl:h-[151px]">
<Image
src={resolvePartnerImage(partner.avatar, index)}
alt={partner.name ?? ""}
width={140}
height={72}
className="max-h-full w-full object-contain"
/>
</div>
</a>
) : (
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] xl:h-[151px]">
<Image
src={resolvePartnerImage(partner.avatar, index)}
alt={partner.name ?? ""}
width={140}
height={72}
className="max-h-full w-full object-contain"
/>
</div>
)}
</SwiperSlide>
))}
</Swiper>
);
}
return (
<div className="rounded-[16px] border border-[#edf1f7] bg-white px-5 py-10 text-center text-sm text-gray-500">
Chưa có thông tin.
</div>
);
};
function VideoAndPartners() {
const { data: videosData, isLoading: videosLoading } = useGetApiV10Video(
{
page: 1,
pageSize: 2,
sortField: "created_at",
sortOrder: "desc",
},
{
query: {
staleTime: 60 * 1000,
select: (response) => {
const rows = (response?.responseData?.rows ?? []) as unknown as Video[];
return rows.map(toClientVideo);
},
},
},
);
const { data: partnersResponse } = useGetOrganizations<Organization[] | undefined>(
{
filters: "type==SPONSOR",
pageSize: "12",
sortField: "sort_order",
sortOrder: "ASC",
},
{
query: {
staleTime: 60 * 1000,
select: (response) => {
const rows = (response as any)?.responseData?.rows ?? [];
return rows.length > 0 ? rows : MOCK_PARTNER_ROWS;
},
},
},
);
const videos = videosData ?? [];
const partners = (partnersResponse ?? MOCK_PARTNER_ROWS).slice(0, 12);
const displayPartners = partners;
return (
<section className="flex flex-col gap-6 pb-10 xl:flex-row xl:items-stretch">
<div className="flex-1">
<div className="mb-5 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Video
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
<Link
href="/video"
className="pt-1 text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<ChevronRight className="h-5 w-5" />
</Link>
</div>
<div className="grid gap-4 md:grid-cols-2">
{videosLoading ? (
Array.from({ length: 2 }).map((_, index) => (
<div
key={`video-loading-${index}`}
className="h-[202px] animate-pulse rounded-[16px] bg-[#edf1f7]"
/>
))
) : videos.length ? (
videos.map((video) => (
<a
key={video.id}
href={video.watchUrl}
target="_blank"
rel="noreferrer"
className="overflow-hidden rounded-[16px] border border-[#e5ebf4] bg-white shadow-[0_10px_22px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_16px_30px_rgba(31,59,124,0.12)]"
>
<div className="group relative aspect-[1.95/1] overflow-hidden">
<Image
src={video.thumbnail}
alt={video.name}
width={640}
height={440}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/18" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/92 text-[#2a4ea3] shadow-[0_10px_26px_rgba(0,0,0,0.18)]">
<Play className="ml-1 h-5 w-5 fill-current" />
</span>
</div>
</div>
<div className="px-4 py-2.5">
<p className="line-clamp-2 text-[14px] font-semibold leading-[1.32] text-[#264798] md:text-[15px]">
{video.name}
</p>
</div>
</a>
))
) : (
<div className="rounded-[16px] border border-[#e5ebf4] bg-white px-5 py-10 text-center text-sm text-gray-500 md:col-span-2">
Chưa có video nào.
</div>
)}
</div>
</div>
<aside className="flex w-full flex-col xl:w-[43%]">
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Đối tác
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
</div>
{renderPartnerContent(displayPartners)}
</aside>
</section>
);
}
export default VideoAndPartners;
import { getApiV10Video } from "@/api/vcci-news/endpoints/video";
import type { PagedResult } from "@/api/vcci-news/types/paged-result";
import type { Video } from "@/api/vcci-news/models/video";
import { getVideoThumbnail, normalizeVideoUrl } from "@/lib/utils/video";
import { ChevronRight, Play } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
const PAGE_SIZE = 2;
const SECTION_PARAMS = {
page: 1,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
} as const;
type VideoItem = {
id: string;
name: string;
thumbnail: string;
watchUrl: string;
};
const toVideoItem = (video: Video): VideoItem => ({
id: String(video.id ?? ""),
name: String(video.name ?? ""),
thumbnail: getVideoThumbnail(String(video.url ?? "")),
watchUrl: normalizeVideoUrl(String(video.url ?? "")),
});
const fetchVideos = async (): Promise<VideoItem[]> => {
try {
const response = await getApiV10Video(SECTION_PARAMS);
const rows =
(response as PagedResult<Video> | undefined)?.responseData?.rows ?? [];
if (rows.length === 0) throw new Error("Empty rows");
return rows.map(toVideoItem);
} catch (error) {
console.warn("[Videos] CMS unavailable, returning empty list", error);
return [];
}
};
export function VideosSkeleton() {
return (
<div className="flex-1">
<div className="mb-5 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Video
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
<Link
href="/video"
className="pt-1 text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<ChevronRight className="h-5 w-5" />
</Link>
</div>
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 2 }, (_, index) => (
<div
key={`video-skeleton-${index}`}
className="h-[202px] animate-pulse rounded-[16px] bg-[#edf1f7]"
/>
))}
</div>
</div>
);
}
export async function Videos() {
const videos = await fetchVideos();
return (
<div className="flex-1">
<div className="mb-5 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Video
</h2>
<div className="mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]" />
</div>
<Link
href="/video"
className="pt-1 text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<ChevronRight className="h-5 w-5" />
</Link>
</div>
<div className="grid gap-4 md:grid-cols-2">
{videos.length ? (
videos.map((video) => (
<a
key={video.id}
href={video.watchUrl}
target="_blank"
rel="noreferrer"
className="overflow-hidden rounded-[16px] border border-[#e5ebf4] bg-white shadow-[0_10px_22px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_16px_30px_rgba(31,59,124,0.12)]"
>
<div className="group relative aspect-[1.95/1] overflow-hidden">
<Image
src={video.thumbnail}
alt={video.name}
width={640}
height={440}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/18" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/92 text-[#2a4ea3] shadow-[0_10px_26px_rgba(0,0,0,0.18)]">
<Play className="ml-1 h-5 w-5 fill-current" />
</span>
</div>
</div>
<div className="px-4 py-2.5">
<p className="line-clamp-2 text-[14px] font-semibold leading-[1.32] text-[#264798] md:text-[15px]">
{video.name}
</p>
</div>
</a>
))
) : (
<div className="rounded-[16px] border border-[#e5ebf4] bg-white px-5 py-10 text-center text-sm text-gray-500 md:col-span-2">
Chưa có video nào.
</div>
)}
</div>
</div>
);
}
"use client";
import { useGetApiV10AdvertisementPublic } from "@/api/vcci-news/endpoints/advertisement";
import type { Advertisement } from "@/api/vcci-news/models/advertisement";
import type { GetApiV10AdvertisementPublicType } from "@/api/vcci-news/models/getApiV10AdvertisementPublicType";
/**
* Hook đọc danh sách quảng cáo active theo loại từ API backend (public, không cần auth).
* Backend đã lọc status=ACTIVE và sort theo sort_order ASC.
* Mỗi record có `file` (path, mime) — dùng `links.resolveImageUrl(file.path)` để lấy URL ảnh.
*
* @param type - "square" | "horizontal"
* @param limit - số lượng records tối đa (mặc định 20 cho square, 1 cho horizontal)
*/
export function useAdvertisements(
type: "square" | "horizontal",
limit?: number,
): Advertisement[] {
const effectiveLimit = limit ?? (type === "horizontal" ? 1 : 5);
const { data } = useGetApiV10AdvertisementPublic({
type: type as GetApiV10AdvertisementPublicType,
limit: effectiveLimit,
});
// Backend trả về { responseData: [...] }
return (
(data as unknown as { responseData?: Advertisement[] } | undefined)?.responseData ?? []
);
}
This diff is collapsed.
import FeaturedNews from "./components/featured-news";
import Advertisements from "./components/quick-links";
import HorizontalAdBanner from "./components/horizontal-ad-banner";
import News from "./components/news";
import Events from "./components/events";
import BusinessOpportunities from "./components/business-opportunities";
import PolicyAndLaws from "./components/policies-and-laws";
import EventsCalendar from "./components/events-calendar";
import Banner from "./components/banner";
import Members from "./components/members";
import VideoAndPartners from "./components/video-and-patners";
import { Suspense } from "react";
import { FeaturedNews, FeaturedNewsSkeleton } from "./components/featured-news";
import { Advertisements } from "./components/advertisements";
import { HorizontalAdBanner } from "./components/horizontal-ad-banner";
import { News } from "./components/news";
import { Events, EventsSkeleton } from "./components/events";
import { BusinessOpportunities, BusinessOpportunitiesSkeleton } from "./components/business-opportunities";
import { PolicyAndLaws, PolicyAndLawsSkeleton } from "./components/policies-and-laws";
import { EventsCalendar } from "@/components/base/events-calendar";
import { Banner } from "./components/banner";
import { FeaturedMembers } from "./components/featured-members";
import { MemberConnection, MemberConnectionSkeleton } from "./components/member-connection";
import { Videos, VideosSkeleton } from "./components/videos";
import { Partners } from "./components/partners";
export default function HomePage() {
return (
......@@ -19,7 +22,9 @@ export default function HomePage() {
<Banner />
{/* contents */}
<div className="container mx-auto px-3 sm:px-6 lg:px-10 space-y-6">
<Suspense fallback={<FeaturedNewsSkeleton />}>
<FeaturedNews />
</Suspense>
<section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0">
<News />
......@@ -29,7 +34,9 @@ export default function HomePage() {
<HorizontalAdBanner />
<section className="flex flex-col gap-5 xl:flex-row xl:items-stretch" >
<Suspense fallback={<EventsSkeleton />}>
<Events />
</Suspense>
<EventsCalendar />
</section >
......@@ -37,16 +44,30 @@ export default function HomePage() {
<div className="flex flex-col flex-1">
<section className="flex flex-col xl:flex-row gap-5">
<div className="flex flex-col md:flex-row gap-5 pt-8 flex-1 order-2 xl:order-1">
<Suspense fallback={<BusinessOpportunitiesSkeleton />}>
<BusinessOpportunities />
</Suspense>
<Suspense fallback={<PolicyAndLawsSkeleton />}>
<PolicyAndLaws />
</Suspense>
</div>
<Advertisements count={2} startIndex={3} />
</section>
</div>
</div >
<Members />
<VideoAndPartners />
<section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch">
<FeaturedMembers />
<Suspense fallback={<MemberConnectionSkeleton />}>
<MemberConnection />
</Suspense>
</section>
<section className="flex flex-col gap-6 pb-10 xl:flex-row xl:items-stretch">
<Suspense fallback={<VideosSkeleton />}>
<Videos />
</Suspense>
<Partners />
</section>
</div>
</div>
);
......
......@@ -21,9 +21,6 @@ import Fees from "./static-pages/Fees";
import Locations from "./static-pages/Locations";
import Contact from "./static-pages/Contact";
import MemberDirectory from "./static-pages/MemberDirectory";
import Search from "./static-pages/Search";
import SiteMap from "./static-pages/SiteMap";
import Video from "./static-pages/Video";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import { useGetApiV10Category } from "@/api/vcci-news/endpoints/category";
import type { DynamicCategoryRouteItem } from "./templates/types";
......@@ -66,7 +63,7 @@ export default function DynamicPageClient() {
"quy-trinh-tiep-nhan-ho-so-cap-gcn-va-xac-nhan-cttm",
"bieu-mau-gcn-va-noi-dung-khai-bao-gcn-cttm", "phi-cap-gcn-va-xac-nhan-cttm",
"diem-cap-va-thoi-gian-cap-gcn-va-xac-nhan-cttm", "thong-tin-lien-he",
"danh-ba-hoi-vien", "search", "site-map", "video",
"danh-ba-hoi-vien",
].includes(endingSlug);
const { data: categoryData, isLoading: categoryLoading } = useGetApiV10Category({
......@@ -289,27 +286,6 @@ export default function DynamicPageClient() {
allCategories={allCategories}
/>
);
case "search":
return (
<Search
category={matchedCategory}
allCategories={allCategories}
/>
);
case "site-map":
return (
<SiteMap
category={matchedCategory}
allCategories={allCategories}
/>
);
case "video":
return (
<Video
category={matchedCategory}
allCategories={allCategories}
/>
);
}
if (detailPost) {
......
......@@ -18,7 +18,7 @@ const STATIC_PAGE_SLUGS = new Set([
"quy-trinh-tiep-nhan-ho-so-cap-gcn-va-xac-nhan-cttm",
"bieu-mau-gcn-va-noi-dung-khai-bao-gcn-cttm", "phi-cap-gcn-va-xac-nhan-cttm",
"diem-cap-va-thoi-gian-cap-gcn-va-xac-nhan-cttm", "thong-tin-lien-he",
"danh-ba-hoi-vien", "search", "site-map", "video",
"danh-ba-hoi-vien",
]);
const SITE_NAME = "VCCI HCM";
......@@ -100,21 +100,6 @@ const STATIC_PAGE_METADATA: Record<string, { title: string; description: string
description:
"Danh bạ hội viên VCCI-HCM: tra cứu thông tin doanh nghiệp hội viên và kết nối đối tác kinh doanh.",
},
"search": {
title: "Tìm kiếm",
description:
"Tìm kiếm tin tức, bài viết và nội dung trên website VCCI-HCM theo từ khóa.",
},
"site-map": {
title: "Sơ đồ site",
description:
"Sơ đồ tổng quan các chuyên mục và trang trên website VCCI-HCM, hỗ trợ điều hướng nhanh.",
},
"video": {
title: "Video",
description:
"Thư viện video VCCI-HCM: các phóng sự, sự kiện, hội thảo và hoạt động xúc tiến thương mại.",
},
};
/**
......
......@@ -8,7 +8,7 @@ import { Pagination } from "@/components/base/pagination";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import { EventsCalendar } from "@/components/base/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import ListCategory from "@/components/base/list-category";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
......
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import ListCategory from "@/components/base/list-category";
import { buildDynamicCategoryMenu } from "../templates/data";
import type { DynamicCategoryRouteItem } from "../templates/types";
import { getApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import type { HeaderCategoryTreeItem } from "@/api/vcci-news/types/header-config";
type SiteMapProps = {
category: DynamicCategoryRouteItem | null;
allCategories: DynamicCategoryRouteItem[];
};
export default function SiteMap({ category, allCategories }: SiteMapProps) {
const [categoriesData, setCategoriesData] = useState<{ responseData?: HeaderCategoryTreeItem[] } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
void getApiV10CategoryTree()
.then((response) => {
setCategoriesData(response as { responseData?: HeaderCategoryTreeItem[] });
setIsLoading(false);
})
.catch(() => {
setIsError(true);
setIsLoading(false);
});
}, []);
const categoryMenu = category ? buildDynamicCategoryMenu(category, allCategories) : [];
if (isLoading) {
return (
<>
{categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null}
<div className="min-h-screen bg-gray-50 py-12 flex items-center justify-center">
<div className="text-[#063e8e] text-xl font-semibold">Đang tải...</div>
</div>
</>
);
}
if (isError || !categoriesData?.responseData) {
return (
<>
{categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null}
<div className="min-h-screen bg-gray-50 py-12 flex items-center justify-center">
<div className="text-red-600 text-xl font-semibold">Không thể tải dữ liệu</div>
</div>
</>
);
}
const sections = categoriesData?.responseData ?? [];
return (
<>
{categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null}
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<h1 className="text-3xl font-bold text-center mb-12 text-[#063e8e]">
SƠ ĐỒ TRANG WEB
</h1>
{/* Sitemap Structure */}
<div className="relative flex flex-col items-center">
{/* Homepage - Top Level */}
<div className="relative mb-20">
<Link
href="/"
className="block bg-[#063e8e] text-white px-8 py-4 rounded-lg font-semibold text-center hover:bg-[#0a4fb5] transition shadow-lg min-w-[200px]"
>
TRANG CHỦ
</Link>
{/* Vertical line from homepage down */}
<div className="absolute left-[99px] -translate-x-1/2 top-full h-20 w-0.5 bg-gray-600"></div>
</div>
{/* Main Sections - Second Level */}
<div className="relative w-full max-w-[1400px]">
{/* Horizontal line connecting all sections */}
<div className="absolute top-0 left-[6.3%] right-[6.3%] h-0.5 bg-gray-600 z-0"></div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-6 relative pt-4">
{sections.map((section, idx) => (
<div key={section.id} className="relative flex flex-col items-center">
{/* Vertical line from horizontal bar down to section */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 h-4 w-0.5 bg-gray-600 z-10"></div>
{/* Section Box */}
<div className="relative z-20">
<Link
href={section.url || "#"}
className="flex bg-[#063e8e] text-white px-4 py-3 rounded-md font-medium text-center hover:bg-[#0a4fb5] transition shadow-md w-full text-sm min-h-20 items-center justify-center"
>
<span className="leading-tight">{section.name.toUpperCase()}</span>
</Link>
{/* Vertical line from section down to children */}
{section.children && section.children.length > 0 && (
<div className="absolute left-1/2 -translate-x-1/2 top-full h-6 w-0.5 bg-gray-600 z-10"></div>
)}
</div>
{/* Children - Third Level */}
{section.children && section.children.length > 0 && (
<div className="mt-6 flex flex-col gap-3 w-full relative">
{/* Vertical spine connecting all children */}
<div
className="absolute left-1/2 -translate-x-1/2 w-0.5 bg-gray-600"
style={{
top: '-24px',
bottom: '0',
}}
></div>
{section.children.map((child, childIdx) => (
<div key={child.id} className="relative">
{/* Horizontal line from spine to child box */}
<div className="absolute right-1/2 top-1/2 -translate-y-1/2 w-1/2 h-0.5 bg-gray-600"></div>
<Link
href={child.url || "#"}
className="block bg-gray-400 text-white px-3 py-2.5 rounded text-xs font-medium text-center hover:bg-gray-500 transition shadow-sm leading-tight relative z-10"
>
{child.name.toUpperCase()}
</Link>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
<style jsx>{`
@media (max-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
`}</style>
</div>
</>
);
}
......@@ -8,7 +8,7 @@ import { Pagination } from "@/components/base/pagination";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import { EventsCalendar } from "@/components/base/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import ListCategory from "@/components/base/list-category";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
......
"use client";
import { Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Play } from "lucide-react";
import Image from "next/image";
import { Pagination } from "@/components/base/pagination";
import { Spinner } from "@/components/ui/spinner";
import ListCategory from "@/components/base/list-category";
import { buildDynamicCategoryMenu } from "../templates/data";
import type { DynamicCategoryRouteItem } from "../templates/types";
import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video";
import type { Video } from "@/api/vcci-news/models/video";
import { getVideoThumbnail, normalizeVideoUrl } from "@/lib/utils/video";
function VideoPageContent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const pageFromUrl = Number(searchParams.get("page") ?? "1");
const page = Number.isFinite(pageFromUrl) && pageFromUrl > 0 ? Math.floor(pageFromUrl) : 1;
const PAGE_SIZE = 10;
const { data: videosData, isLoading: videosLoading, isError: videosError } = useGetApiV10Video({
page,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
});
const pageData = videosData?.responseData;
const count = pageData?.count ?? 0;
const videoPageSize = pageData?.pageSize ?? PAGE_SIZE;
const videos = ((pageData?.rows ?? []) as unknown as Video[]).map((item) => ({
...item,
thumbnail: getVideoThumbnail(item.url ?? ""),
watchUrl: normalizeVideoUrl(item.url ?? ""),
}));
const totalPages = Math.max(1, Math.ceil(count / videoPageSize));
const currentPage = pageData?.page ?? page;
const updatePage = (nextPage: number) => {
const params = new URLSearchParams(searchParams.toString());
if (nextPage > 1) {
params.set("page", String(nextPage));
} else {
params.delete("page");
}
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false });
};
return (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10">
<div className="mb-8">
<h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
Video
</h1>
<div className="mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]" />
</div>
{videosLoading ? (
<div className="grid gap-6 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={`video-page-loading-${index}`}
className="h-[280px] animate-pulse rounded-[18px] bg-[#edf1f7]"
/>
))}
</div>
) : videosError ? (
<div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600">
Không thể tải danh sách video.
</div>
) : videos.length ? (
<div className="space-y-8">
<div className="grid gap-6 md:grid-cols-2">
{videos.map((video) => (
<a
key={video.id}
href={video.watchUrl}
target="_blank"
rel="noreferrer"
className="group overflow-hidden rounded-[18px] border border-[#e5ebf4] bg-white shadow-[0_12px_30px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_38px_rgba(31,59,124,0.14)]"
>
<div className="relative aspect-video overflow-hidden bg-[#edf1f5]">
<Image
src={video.thumbnail}
alt={video.name}
width={900}
height={506}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-black/20" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-white/92 text-[#24469c] shadow-[0_12px_30px_rgba(0,0,0,0.2)]">
<Play className="ml-1 h-6 w-6 fill-current" />
</span>
</div>
</div>
<div className="p-4 sm:p-5">
<h2 className="line-clamp-2 text-[17px] font-bold leading-snug text-[#1f3f91] transition-colors group-hover:text-[#0f4386]">
{video.name}
</h2>
</div>
</a>
))}
</div>
{totalPages > 1 ? (
<div className="flex justify-center">
<Pagination
pageCount={totalPages}
page={currentPage}
onChangePage={updatePage}
onGoToPreviousPage={() => updatePage(Math.max(1, currentPage - 1))}
onGoToNextPage={() => updatePage(Math.min(totalPages, currentPage + 1))}
/>
</div>
) : null}
</div>
) : (
<div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600">
Chưa có video nào.
</div>
)}
</div>
</div>
);
}
type VideoProps = {
category: DynamicCategoryRouteItem | null;
allCategories: DynamicCategoryRouteItem[];
};
export default function Video({ category, allCategories }: VideoProps) {
const categoryMenu = category ? buildDynamicCategoryMenu(category, allCategories) : [];
return (
<>
{categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null}
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-white">
<Spinner className="size-8" />
</div>
}
>
<VideoPageContent />
</Suspense>
</>
);
}
'use client';
import dayjs from "dayjs";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import { EventsCalendar } from "@/components/base/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { findDisplayCategoryForPost } from "../data";
import { renderPostSummary } from "./components/post-summary-content";
......
......@@ -9,7 +9,7 @@ import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import { EventsCalendar } from "@/components/base/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import {
......
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
import { Pagination } from "@/components/base/pagination";
import { EventsCalendar } from "@/components/base/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Spinner } from "@/components/ui";
import { Button } from "@/components/ui/button";
import {
buildDynamicPostHref,
resolveDynamicPostImage,
stripHtml,
} from "../[...slug]/templates/data";
const PAGE_SIZE = 10;
const CURRENT_YEAR = new Date().getFullYear();
const START_YEAR = 2020;
const YEAR_OPTIONS = Array.from(
{ length: CURRENT_YEAR - START_YEAR + 1 },
(_, index) => CURRENT_YEAR - index,
);
const formatPostDate = (value?: string | null) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
};
export default function NewsListing() {
const [year, setYear] = useState<number | null>(null);
const [page, setPage] = useState(1);
const filterParts = useMemo(() => {
const parts = ["is_hidden==false", "is_active==true", "type==news"];
if (year) {
parts.push(`published_at>=${year}-01-01`);
parts.push(`published_at<=${year}-12-31`);
}
return parts.join(",");
}, [year]);
const { data: postsData, isLoading: postsLoading } = useGetApiV10Post({
page,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
priorityFeatured: false,
filters: filterParts,
});
const responseData = postsData?.responseData;
const count = Number(responseData?.count ?? 0);
const totalPages = PAGE_SIZE > 0 ? Math.max(1, Math.ceil(count / PAGE_SIZE)) : 1;
const currentPage = Math.min(page, totalPages);
const posts = ((responseData?.rows ?? []) as unknown as DynamicPostItem[]).filter(
(item) => item.id && item.title,
);
const handleSelectYear = (value: number | null) => {
setYear(value);
setPage(1);
};
return (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10">
<div className="mb-8">
<h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
Tin tức
</h1>
<div className="mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]" />
</div>
<div className="flex flex-col gap-10 xl:flex-row xl:gap-14">
<main className="order-2 min-w-0 xl:order-1 xl:flex-1">
{postsLoading ? (
<div className="flex h-64 w-full items-center justify-center">
<Spinner />
</div>
) : (
<div className="space-y-9">
{posts.length ? (
posts.map((item) => {
const date = formatPostDate(
item.release_at ?? item.published_at ?? item.created_at,
);
const categoryName = item.categories?.[0]?.name ?? "";
const excerpt = stripHtml(item.summary) || "";
return (
<article
key={item.id}
className="border-b border-[#eceff3] pb-8 last:border-b-0"
>
<Link
href={buildDynamicPostHref(item.slug, item.id)}
className="group grid items-center gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="relative aspect-[25/15] overflow-hidden rounded-md bg-[#edf1f5] sm:aspect-[5/3]">
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
height={360}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3 text-xs">
{categoryName ? (
<span className="rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]">
{categoryName}
</span>
) : null}
{date ? (
<span className="text-[#9aa3ad]">{date}</span>
) : null}
</div>
<h2 className="mt-3 line-clamp-2 text-[18px] font-bold leading-snug text-[#111827] transition-colors group-hover:text-[#144c9c]">
{item.title}
</h2>
{excerpt ? (
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[#5f6875]">
{excerpt}
</p>
) : null}
</div>
</Link>
</article>
);
})
) : (
<div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600">
<p>
{year
? `Chưa có bài viết nào trong năm ${year}.`
: "Chưa có bài viết nào."}
</p>
</div>
)}
<div className="flex w-full justify-center pt-2">
<Pagination
pageCount={totalPages}
page={currentPage}
onChangePage={setPage}
onGoToPreviousPage={() =>
setPage(Math.max(1, currentPage - 1))
}
onGoToNextPage={() =>
setPage(Math.min(totalPages, currentPage + 1))
}
/>
</div>
</div>
)}
</main>
<aside className="contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5">
<div className="order-1 rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)] xl:order-0">
<h2 className="text-lg font-bold text-[#111827]">
Lọc theo năm
</h2>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleSelectYear(null)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition-all ${year === null
? "bg-[#14519f] text-white shadow-[0_10px_20px_rgba(20,81,159,0.18)]"
: "bg-[#f4f7fb] text-[#5f6875] hover:bg-[#eaf0f8]"
}`}
>
Tất cả
</button>
{YEAR_OPTIONS.map((option) => {
const active = year === option;
return (
<button
key={option}
type="button"
onClick={() => handleSelectYear(active ? null : option)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition-all ${active
? "bg-[#14519f] text-white shadow-[0_10px_20px_rgba(20,81,159,0.18)]"
: "bg-[#f4f7fb] text-[#5f6875] hover:bg-[#eaf0f8]"
}`}
>
{option}
</button>
);
})}
</div>
{year !== null ? (
<Button
type="button"
variant="outline"
className="mt-4 h-10 w-full rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick={() => handleSelectYear(null)}
>
Bỏ lọc năm
</Button>
) : null}
</div>
<EventsCalendar compact className="xl:w-full xl:min-w-0" />
<SidebarAdvertisements count={5} startIndex={0} />
</aside>
</div>
</div>
</div>
);
}
import type { Metadata } from "next";
import links from "@links/index";
import NewsListing from "./news-listing";
export const metadata: Metadata = {
title: "Tin tức",
description:
"Tổng hợp toàn bộ tin tức của VCCI HCM: tin VCCI, tin kinh tế, chuyên đề và các bài viết mới nhất.",
alternates: {
canonical: `${links.siteURL.replace(/\/+$/, "")}/news`,
},
};
export default function NewsPage() {
return <NewsListing />;
}
import type { Metadata } from "next";
import links from "@links/index";
import SearchListing from "./search-listing";
type SearchPageProps = {
searchParams: Promise<{ q?: string; page?: string }>;
};
export async function generateMetadata({
searchParams,
}: SearchPageProps): Promise<Metadata> {
const { q } = await searchParams;
const query = (q ?? "").trim();
const siteUrl = links.siteURL.replace(/\/+$/, "");
return {
title: query ? `Tìm kiếm: ${query}` : "Tìm kiếm",
description: query
? `Kết quả tìm kiếm "${query}" trên cổng thông tin VCCI-HCM.`
: "Tìm kiếm bài viết trên cổng thông tin VCCI-HCM.",
alternates: {
canonical: `${siteUrl}/search`,
},
};
}
export default function SearchPage({ searchParams }: SearchPageProps) {
return <SearchListing searchParams={searchParams} />;
}
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type SearchFormProps = {
defaultValue?: string;
};
/**
* Client island cho form tìm kiếm, đẩy `q` + `page` lên URL
* để server component đọc searchParams và fetch dữ liệu.
*/
export default function SearchForm({ defaultValue = "" }: SearchFormProps) {
const router = useRouter();
const [value, setValue] = useState(defaultValue);
useEffect(() => {
setValue(defaultValue);
}, [defaultValue]);
const goToSearch = (nextQuery: string) => {
const params = new URLSearchParams();
const trimmedQuery = nextQuery.trim();
if (trimmedQuery) params.set("q", trimmedQuery);
params.set("page", "1");
router.push(`/search?${params.toString()}`, { scroll: false });
};
return (
<form
className="order-1 rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)] xl:order-none"
onSubmit={(event) => {
event.preventDefault();
goToSearch(value);
}}
>
<h2 className="text-lg font-bold text-[#111827]">Tìm kiếm</h2>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Tên bài viết ..."
className="mt-4 h-11 rounded-xl border-[#edf1f5] bg-[#f8fafc] text-sm placeholder:text-gray-700"
/>
<div className="mt-4 grid grid-cols-2 gap-3">
<Button
type="submit"
className="h-11 rounded-xl bg-[#14519f] text-white hover:bg-[#0f4386]"
>
Tìm kiếm
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick={() => {
setValue("");
goToSearch("");
}}
>
Bỏ tìm
</Button>
</div>
</form>
);
}
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import { getApiV10Post } from "@/api/vcci-news/endpoints/post";
import PaginationNav from "@/components/base/pagination-nav";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Pagination } from "@components/base/pagination";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@components/ui/spinner";
import ListCategory from "@/components/base/list-category";
import { buildDynamicCategoryMenu } from "../templates/data";
import type { DynamicCategoryRouteItem } from "../templates/types";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import {
buildDynamicPostHref,
getDynamicPostExcerpt,
resolveDynamicPostImage,
mapPost,
} from "../templates/data";
resolveDynamicPostImage,
} from "../[...slug]/templates/data";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
import SearchForm from "./search-form";
const PAGE_SIZE = 10;
const formatPostDate = (value?: string | null) => {
if (!value) return "";
......@@ -68,7 +61,9 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
<div className="min-w-0 pt-1">
<div className="flex flex-wrap items-center gap-3 text-xs">
<span className={`rounded-full px-2.5 py-1 font-semibold ${getTagClassName(index)}`}>
<span
className={`rounded-full px-2.5 py-1 font-semibold ${getTagClassName(index)}`}
>
{categoryName}
</span>
{date ? <span className="text-[#9aa3ad]">{date}</span> : null}
......@@ -89,15 +84,18 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
);
}
function SearchContent() {
const router = useRouter();
const searchParams = useSearchParams();
const query = searchParams.get("q") || "";
const pageFromUrl = searchParams.get("page");
const [page, setPage] = useState(pageFromUrl ? Number(pageFromUrl) : 1);
const [searchInput, setSearchInput] = useState(query);
type SearchListingProps = {
searchParams: Promise<{ q?: string; page?: string }>;
};
export default async function SearchListing({ searchParams }: SearchListingProps) {
const { q, page: pageParam } = await searchParams;
const query = (q ?? "").trim();
const pageFromUrl = Number(pageParam ?? "1");
const page =
Number.isFinite(pageFromUrl) && pageFromUrl > 0 ? Math.floor(pageFromUrl) : 1;
const pageSize = 10;
const filters = [
query ? `title@=${query}` : null,
"is_hidden==false",
......@@ -108,41 +106,39 @@ function SearchContent() {
.filter(Boolean)
.join(",");
const { data: postsData, isLoading: postsLoading } = useGetApiV10Post({
let rows: DynamicPostItem[] = [];
let count = 0;
let pageSize = PAGE_SIZE;
let currentPage = page;
try {
const response = (await getApiV10Post({
page,
pageSize,
pageSize: PAGE_SIZE,
sortField: "release_at",
sortOrder: "desc",
filters: filters || undefined,
});
filters,
})) as {
responseData?: {
count?: number;
page?: number;
pageSize?: number;
rows?: unknown[];
};
};
const responseData = postsData?.responseData;
const count = Number(responseData?.count ?? 0);
const totalPages = pageSize > 0 ? Math.max(1, Math.ceil(count / pageSize)) : 1;
const currentPage = Math.min(page, totalPages);
const rows = ((responseData?.rows ?? []) as unknown as Parameters<typeof mapPost>[0][])
const pageData = response?.responseData;
count = Number(pageData?.count ?? 0);
pageSize = Number(pageData?.pageSize ?? PAGE_SIZE) || PAGE_SIZE;
currentPage = Number(pageData?.page ?? page) || page;
rows = ((pageData?.rows ?? []) as unknown as Parameters<typeof mapPost>[0][])
.map(mapPost)
.filter((item: DynamicPostItem) => item.id && item.title);
useEffect(() => {
const nextPage = pageFromUrl ? Number(pageFromUrl) : 1;
if (Number.isFinite(nextPage)) {
setPage(nextPage);
.filter((item) => item.id && item.title);
} catch {
rows = [];
}
}, [pageFromUrl]);
useEffect(() => {
setSearchInput(query);
}, [query]);
const updateSearchUrl = (nextQuery: string, nextPage = 1) => {
const params = new URLSearchParams();
const trimmedQuery = nextQuery.trim();
if (trimmedQuery) params.set("q", trimmedQuery);
params.set("page", String(nextPage));
router.push(`/search?${params.toString()}`, { scroll: false });
};
const totalPages = Math.max(1, Math.ceil(count / pageSize));
return (
<div className="min-h-screen bg-white">
......@@ -154,19 +150,14 @@ function SearchContent() {
<div className="mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]" />
{query ? (
<p className="mt-4 text-sm text-[#5f6875]">
Kết quả tìm kiếm cho: <span className="font-semibold text-[#111827]">{query}</span>
Kết quả tìm kiếm cho:{" "}
<span className="font-semibold text-[#111827]">{query}</span>
</p>
) : null}
</div>
<div className="flex flex-col gap-10 xl:flex-row xl:gap-14">
<main className="order-2 min-w-0 xl:order-1 xl:flex-1">
{postsLoading ? (
<div className="flex items-center justify-center py-16">
<Spinner className="size-8" />
<span className="ml-2 text-gray-600">Đang tìm kiếm...</span>
</div>
) : (
<div className="space-y-9">
{rows.length ? (
rows.map((item, index) => (
......@@ -178,67 +169,17 @@ function SearchContent() {
</div>
)}
<div className="flex w-full justify-center pt-2">
<Pagination
<PaginationNav
page={Math.min(currentPage, totalPages)}
pageCount={totalPages}
page={currentPage}
onChangePage={(nextPage) => {
setPage(nextPage);
updateSearchUrl(query, nextPage);
}}
onGoToPreviousPage={() => {
const nextPage = Math.max(1, currentPage - 1);
setPage(nextPage);
updateSearchUrl(query, nextPage);
}}
onGoToNextPage={() => {
const nextPage = Math.min(totalPages, currentPage + 1);
setPage(nextPage);
updateSearchUrl(query, nextPage);
}}
basePath="/search"
keepParams={[{ key: "q", value: query }]}
/>
</div>
</div>
)}
</main>
<aside className="contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5 xl:pt-0">
<form
className="order-1 rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)] xl:order-none"
onSubmit={(event) => {
event.preventDefault();
setPage(1);
updateSearchUrl(searchInput, 1);
}}
>
<h2 className="text-lg font-bold text-[#111827]">Tìm kiếm</h2>
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Tên bài viết ..."
className="mt-4 h-11 rounded-xl border-[#edf1f5] bg-[#f8fafc] text-sm placeholder:text-gray-700"
/>
<div className="mt-4 grid grid-cols-2 gap-3">
<Button
type="submit"
className="h-11 rounded-xl bg-[#14519f] text-white hover:bg-[#0f4386]"
>
Tìm kiếm
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick={() => {
setSearchInput("");
setPage(1);
updateSearchUrl("", 1);
}}
>
Bỏ tìm
</Button>
</div>
</form>
<SearchForm defaultValue={query} />
<SidebarAdvertisements count={5} startIndex={0} />
</aside>
......@@ -247,27 +188,3 @@ function SearchContent() {
</div>
);
}
type SearchProps = {
category: DynamicCategoryRouteItem | null;
allCategories: DynamicCategoryRouteItem[];
};
export default function Search({ category, allCategories }: SearchProps) {
const categoryMenu = category ? buildDynamicCategoryMenu(category, allCategories) : [];
return (
<>
{categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null}
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-white">
<Spinner className="size-8" />
</div>
}
>
<SearchContent />
</Suspense>
</>
);
}
import type { Metadata } from "next";
import links from "@links/index";
import SiteMapListing from "./site-map-listing";
export const metadata: Metadata = {
title: "Sơ đồ trang web",
description: "Sơ đồ trang web VCCI-HCM: tổng quan các chuyên mục và bài viết trên website.",
alternates: {
canonical: `${links.siteURL.replace(/\/+$/, "")}/site-map`,
},
};
export default function SiteMapPage() {
return <SiteMapListing />;
}
import Link from "next/link";
import { getApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import type { HeaderCategoryTreeItem } from "@/api/vcci-news/types/header-config";
export default async function SiteMapListing() {
let sections: HeaderCategoryTreeItem[] = [];
try {
const response = (await getApiV10CategoryTree()) as {
responseData?: HeaderCategoryTreeItem[];
};
sections = response?.responseData ?? [];
} catch {
sections = [];
}
if (!sections.length) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 py-12">
<div className="text-xl font-semibold text-red-600">
Không thể tải dữ liệu
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<h1 className="mb-12 text-center text-3xl font-bold text-[#063e8e]">
SƠ ĐỒ TRANG WEB
</h1>
{/* Sitemap Structure */}
<div className="relative flex flex-col items-center">
{/* Homepage - Top Level */}
<div className="relative mb-20">
<Link
href="/"
className="block min-w-[200px] rounded-lg bg-[#063e8e] px-8 py-4 text-center font-semibold text-white shadow-lg transition hover:bg-[#0a4fb5]"
>
TRANG CHỦ
</Link>
{/* Vertical line from homepage down */}
<div className="absolute left-[99px] top-full h-20 w-0.5 -translate-x-1/2 bg-gray-600" />
</div>
{/* Main Sections - Second Level */}
<div className="relative w-full max-w-[1400px]">
{/* Horizontal line connecting all sections */}
<div className="absolute left-[6.3%] right-[6.3%] top-0 z-0 h-0.5 bg-gray-600" />
<div className="relative grid grid-cols-2 gap-6 pt-4 md:grid-cols-4 lg:grid-cols-7">
{sections.map((section) => (
<div
key={section.id}
className="relative flex flex-col items-center"
>
{/* Vertical line from horizontal bar down to section */}
<div className="absolute -top-4 left-1/2 z-10 h-4 w-0.5 -translate-x-1/2 bg-gray-600" />
{/* Section Box */}
<div className="relative z-20">
<Link
href={section.url || "#"}
className="flex min-h-20 w-full items-center justify-center rounded-md bg-[#063e8e] px-4 py-3 text-center text-sm font-medium text-white shadow-md transition hover:bg-[#0a4fb5]"
>
<span className="leading-tight">
{section.name.toUpperCase()}
</span>
</Link>
{/* Vertical line from section down to children */}
{section.children && section.children.length > 0 ? (
<div className="absolute left-1/2 top-full z-10 h-6 w-0.5 -translate-x-1/2 bg-gray-600" />
) : null}
</div>
{/* Children - Third Level */}
{section.children && section.children.length > 0 ? (
<div className="relative mt-6 flex w-full flex-col gap-3">
{/* Vertical spine connecting all children */}
<div
className="absolute left-1/2 w-0.5 -translate-x-1/2 bg-gray-600"
style={{ top: "-24px", bottom: "0" }}
/>
{section.children.map((child) => (
<div key={child.id} className="relative">
{/* Horizontal line from spine to child box */}
<div className="absolute right-1/2 top-1/2 h-0.5 w-1/2 -translate-y-1/2 bg-gray-600" />
<Link
href={child.url || "#"}
className="relative z-10 block rounded bg-gray-400 px-3 py-2.5 text-center text-xs font-medium leading-tight text-white shadow-sm transition hover:bg-gray-500"
>
{child.name.toUpperCase()}
</Link>
</div>
))}
</div>
) : null}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
import type { Metadata } from "next";
import links from "@links/index";
import VideoListing from "./video-listing";
export const metadata: Metadata = {
title: "Video",
description:
"Thư viện video VCCI-HCM: các phóng sự, sự kiện, hội thảo và hoạt động xúc tiến thương mại.",
alternates: {
canonical: `${links.siteURL.replace(/\/+$/, "")}/video`,
},
};
type VideoPageProps = {
searchParams: Promise<{ page?: string }>;
};
export default function VideoPage({ searchParams }: VideoPageProps) {
return <VideoListing searchParams={searchParams} />;
}
This diff is collapsed.
......@@ -5,7 +5,10 @@ import { addMonths, format, getDay, startOfMonth, subMonths } from "date-fns";
import dayjs from "dayjs";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEventCalendarPosts, type HomePostItem } from "@/app/(main)/(home)/lib/use-home-posts";
import {
useEventCalendarPosts,
type EventCalendarItem,
} from "./use-event-calendar-posts";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
......@@ -14,7 +17,7 @@ const weekDays = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
const formatDateTime = (value: string) =>
value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "Đang cập nhật";
const getEventDateRange = (item: HomePostItem) => {
const getEventDateRange = (item: EventCalendarItem) => {
// Ưu tiên event_dates (ngày cụ thể)
if (item.eventDates && item.eventDates.length > 0) {
return item.eventDates.map((d) => dayjs(d).format("YYYY-MM-DD")).filter(Boolean);
......@@ -49,13 +52,13 @@ const getEventDateRange = (item: HomePostItem) => {
return [];
};
const isTrainingEvent = (item: HomePostItem) =>
const isTrainingEvent = (item: EventCalendarItem) =>
item.categories.some((category) => {
const key = `${category.name} ${category.slug} ${category.url}`.toLowerCase();
return key.includes("đào tạo") || key.includes("dao-tao");
});
const getDayVariant = (items: HomePostItem[]) => {
const getDayVariant = (items: EventCalendarItem[]) => {
const hasTraining = items.some((item) => isTrainingEvent(item));
const hasEvent = items.some((item) => !isTrainingEvent(item));
......@@ -65,7 +68,7 @@ const getDayVariant = (items: HomePostItem[]) => {
return "default";
};
function EventsCalendar({
export function EventsCalendar({
className,
compact = false,
}: {
......@@ -102,7 +105,7 @@ function EventsCalendar({
}, [currentMonth]);
const eventMap = useMemo(() => {
const map = new Map<string, HomePostItem[]>();
const map = new Map<string, EventCalendarItem[]>();
monthEvents.forEach((item) => {
const eventDates = getEventDateRange(item);
......@@ -394,5 +397,3 @@ function EventsCalendar({
</aside>
);
}
\ No newline at end of file
export default EventsCalendar;
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Pagination } from "@/components/base/pagination";
type PaginationNavProps = {
page: number;
pageCount: number;
/** Đường dẫn base, ví dụ `/video`. */
basePath: string;
/** Danh sách query params cần giữ lại khi chuyển trang. */
keepParams?: { key: string; value?: string | null }[];
};
/**
* Client island cho phân trang dùng URL query, dùng được từ Server Component.
*/
export default function PaginationNav({
page,
pageCount,
basePath,
keepParams = [],
}: PaginationNavProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
if (pageCount <= 1) return null;
const goToPage = (nextPage: number) => {
const params = new URLSearchParams();
for (const param of keepParams) {
const value = param.value?.trim();
if (value) params.set(param.key, value);
}
if (nextPage > 1) params.set("page", String(nextPage));
const queryString = params.toString();
router.push(
queryString ? `${pathname || basePath}?${queryString}` : pathname || basePath,
{ scroll: false },
);
};
return (
<div className="flex w-full justify-center pt-2">
<Pagination
pageCount={pageCount}
page={page}
onChangePage={goToPage}
onGoToPreviousPage={() => goToPage(Math.max(1, page - 1))}
onGoToNextPage={() => goToPage(Math.min(pageCount, page + 1))}
/>
</div>
);
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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