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

fix: call api for post and optmize with ssr

parent 0c34b7a8
/**
* 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) => {
......@@ -78,6 +89,4 @@ function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIn
{items}
</aside>
);
}
export default Advertisements;
}
\ No newline at end of file
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);
}
};
if (!rows || rows.length === 0) {
export function Banner() {
const swiperRef = useRef<SwiperType | null>(null);
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}
/>
</SwiperSlide>
);
})}
{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}
......@@ -75,6 +201,4 @@ function BusinessOpportunities() {
</div>
</section>
);
}
export default BusinessOpportunities;
}
\ No newline at end of file
'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 { RawEventPost } from "@/api/vcci-news/types/post-raw";
import type { PagedResult } from "@/api/vcci-news/types/paged-result";
import {
buildPostLink,
normalizeLink,
resolveAssetUrl,
stripHtml,
} from "@/lib/utils/post";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
import dayjs from "dayjs";
import Link from "next/link";
import { useMemo } from "react";
import { ChevronRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { getFallbackImage } from "@/lib/utils/fallback-image";
function Events() {
const { eventPosts, categoryLinks, categoryNames } = useHomePosts();
// Reverse so newest event is first (featured card)
const eventItems = [...eventPosts].reverse();
const SU_KIEN_CATEGORY_ID = "b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a";
const DAO_TAO_CATEGORY_ID = "36df7021-9a74-43d6-9084-0d5ed347b7f4";
const SECTION_LINK_FALLBACK = "/hoat-dong/su-kien";
const PAGE_SIZE = 5;
/** Multi-category filter (OR) — sắp xếp & lọc hoàn toàn ở BE. */
const SECTION_PARAMS = {
page: 1,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
priorityFeatured: false,
filters: [
`category.id==(${SU_KIEN_CATEGORY_ID}|${DAO_TAO_CATEGORY_ID})`,
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
} as const;
type EventItem = {
id: string;
title: string;
externalLink: string;
createdAt: string;
publishedAt: string;
startedAt: string;
contentText: string;
summary: string;
/** Text đã strip HTML (caption/figure/img/tag/entity). */
excerpt: string;
categoryName: string;
thumbnail: { url: string; alt: string } | null;
};
const toEventItem = (post: RawEventPost): EventItem => {
const title = String(post.title ?? "").trim();
const categories = post.categories ?? [];
const thumbnailPath = post.thumbnail?.path ?? post.thumbnail?.original ?? null;
const contentText = String(
post.content_structure?.post_content?.[0]?.content ??
post.summary ??
post.content ??
"",
);
const summary = String(post.summary ?? post.content ?? "");
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 ?? ""),
startedAt: String(post.started_at ?? ""),
contentText,
summary,
excerpt: stripHtml(contentText || summary),
categoryName: String(categories[0]?.name ?? ""),
thumbnail: thumbnailPath
? { url: resolveAssetUrl(thumbnailPath), alt: title }
: null,
};
};
/** Lấy url danh mục sự kiện/đào tạo từ chính danh sách posts, fallback nếu không có. */
const resolveSectionLink = (posts: RawEventPost[]) => {
for (const post of posts) {
const category = post.categories?.find(
(item) =>
(item?.id === SU_KIEN_CATEGORY_ID || item?.id === DAO_TAO_CATEGORY_ID) &&
item?.url &&
item.url !== "#",
);
if (category?.url) return normalizeLink(category.url, SECTION_LINK_FALLBACK);
}
return SECTION_LINK_FALLBACK;
};
const isEventCategoryId = (id?: string | null) =>
id === SU_KIEN_CATEGORY_ID || id === DAO_TAO_CATEGORY_ID;
const fetchEventPosts = async (): Promise<{
posts: EventItem[];
sectionLink: string;
}> => {
try {
const response = await getApiV10Post(SECTION_PARAMS);
const rows =
(response as PagedResult<RawEventPost> | undefined)?.responseData?.rows ?? [];
if (rows.length === 0) throw new Error("Empty rows");
return {
posts: rows.map(toEventItem),
sectionLink: resolveSectionLink(rows),
};
} catch (error) {
console.warn("[Events] CMS unavailable, falling back to mock data", error);
const mockPosts = MOCK_HOME_POSTS.filter((item) =>
item.categories.some((category) => isEventCategoryId(category.id)),
);
const mockSectionLink =
mockPosts
.flatMap((item) => item.categories)
.find((category) => isEventCategoryId(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,
startedAt: item.startedAt,
contentText: item.contentText,
summary: item.summary,
excerpt: stripHtml(item.contentText || item.summary),
categoryName: item.categories[0]?.name ?? "",
thumbnail: item.thumbnail,
})),
sectionLink: mockSectionLink,
};
}
};
export function EventsSkeleton() {
return (
<div className="flex-1 rounded-[16px] bg-linear-to-br from-[#14488f] to-[#2d67bf] p-4 text-white shadow-[0_18px_38px_rgba(16,61,130,0.24)] md:p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h2 className="client-section-title uppercase text-white">
Sự kiện sắp diễn ra
</h2>
<div className="mt-2.5 h-[4px] w-[60px] rounded-full bg-[#f7b500]" />
</div>
</div>
<div className="grid items-stretch gap-3 md:grid-cols-[minmax(0,1.02fr)_minmax(270px,0.98fr)]">
<div className="flex h-full flex-col overflow-hidden rounded-[14px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.12)]">
<div className="h-[180px] animate-pulse bg-[#d7e3f9] md:h-[220px] xl:h-[248px]" />
<div className="space-y-2 p-3 pt-2.5">
<div className="h-6 w-5/6 animate-pulse rounded bg-[#e7eefb]" />
<div className="h-4 w-24 animate-pulse rounded bg-[#eef3fb]" />
</div>
</div>
<div className="grid h-full grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-1">
{Array.from({ length: 4 }, (_, index) => (
<div
key={`events-skeleton-${index}`}
className="flex flex-1 items-center gap-3 rounded-[14px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]"
>
<div className="h-[64px] w-[64px] shrink-0 animate-pulse rounded-[14px] bg-white/20" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 animate-pulse rounded bg-white/25" />
<div className="mt-2 h-3 w-20 animate-pulse rounded bg-white/20" />
</div>
</div>
))}
</div>
</div>
</div>
);
}
export async function Events() {
const { posts, sectionLink } = await fetchEventPosts();
// Giữ nguyên hành vi cũ: đảo ngược danh sách trước khi chọn bài featured
const eventItems = [...posts].reverse();
const [featuredEvent, ...sideEvents] = eventItems;
const sideSlots = Array.from({ length: 4 }, (_, index) => sideEvents[index] ?? null);
const eventsLink =
categoryLinks.get(categoryNames.suKien.toLowerCase()) ?? "/hoat-dong/su-kien";
const featuredFallback = useMemo(() => getFallbackImage(0), []);
const sideFallbacks = useMemo(
() => Array.from({ length: 4 }, (_, i) => getFallbackImage(i + 1)),
[],
);
const featuredFallback = getFallbackImage(0);
const sideFallbacks = Array.from({ length: 4 }, (_, i) => getFallbackImage(i + 1));
return (
<div className="flex-1 rounded-[16px] bg-linear-to-br from-[#14488f] to-[#2d67bf] p-4 text-white shadow-[0_18px_38px_rgba(16,61,130,0.24)] md:p-5">
......@@ -34,7 +206,7 @@ function Events() {
</div>
<Link
href={eventsLink}
href={sectionLink}
className="text-[#ffd34f] transition-colors hover:text-white"
>
<ChevronRight className="h-5 w-5" />
......@@ -56,7 +228,7 @@ function Events() {
className="h-full w-full object-cover"
/>
<span className="absolute left-3 top-3 inline-flex rounded-full bg-[#f7b500] px-3 py-1 text-[12px] font-bold text-[#15357a]">
{featuredEvent.categories[0]?.name || "Sự kiện"}
{featuredEvent.categoryName || "Sự kiện"}
</span>
</div>
......@@ -64,30 +236,11 @@ function Events() {
<h3 className="text-[16px] font-bold uppercase leading-[1.28] text-[#22459b] line-clamp-2 transition-colors duration-200 group-hover:text-[#f7b500] md:text-[18px]">
{featuredEvent.title}
</h3>
{(() => {
const rawText = featuredEvent.contentText || featuredEvent.summary || "";
const textOnly = rawText
.replace(/\[caption[^\]]*\].*?\[\/caption\]/gi, "")
.replace(/<figure[^>]*>.*?<\/figure>/gi, "")
.replace(/<img[^>]*>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/–/g, "–")
.replace(/\s+/g, " ")
.trim();
return textOnly.length > 10 ? (
<p className="mt-2 line-clamp-1 text-[13px] leading-normal text-[#5f6f86]">
{textOnly.substring(0, 150)}
</p>
) : null;
})()}
{featuredEvent.excerpt.length > 10 ? (
<p className="mt-2 line-clamp-1 text-[13px] leading-normal text-[#5f6f86]">
{featuredEvent.excerpt.substring(0, 150)}
</p>
) : null}
<p className="mt-auto pt-2 text-[13px] text-[#90a0bd]">
{dayjs(
featuredEvent.startedAt || featuredEvent.publishedAt || featuredEvent.createdAt,
......@@ -127,37 +280,18 @@ function Events() {
<h4 className="line-clamp-1 text-[15px] font-semibold leading-[1.35] text-white transition-colors duration-200 group-hover:text-[#f7b500]">
{item.title}
</h4>
{(() => {
const rawText = item.contentText || item.summary || "";
const textOnly = rawText
.replace(/\[caption[^\]]*\].*?\[\/caption\]/gi, "")
.replace(/<figure[^>]*>.*?<\/figure>/gi, "")
.replace(/<img[^>]*>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/–/g, "–")
.replace(/\s+/g, " ")
.trim();
return textOnly.length > 10 ? (
<p className="mt-1 line-clamp-1 text-[12px] text-white/78">
{textOnly.substring(0, 80)}
</p>
) : null;
})()}
{item.excerpt.length > 10 ? (
<p className="mt-1 line-clamp-1 text-[12px] text-white/78">
{item.excerpt.substring(0, 80)}
</p>
) : null}
<div className="mt-1 flex items-center gap-2">
{item.categories[0]?.name && (
{item.categoryName && (
<span className="text-[12px] font-medium text-[#f7b500]">
{item.categories[0].name}
{item.categoryName}
</span>
)}
{item.categories[0]?.name && (
{item.categoryName && (
<span className="text-[12px] text-white/50">•</span>
)}
<p className="text-[12px] text-white/78">
......@@ -186,5 +320,3 @@ function Events() {
</div>
);
}
export default Events;
"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;
......@@ -36,6 +51,4 @@ function HorizontalAdBanner() {
/>
</Link>
);
}
export default HorizontalAdBanner;
}
\ No newline at end of file
"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>
);
}
'use client';
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import Image from "next/image";
import { ChevronRight } from "lucide-react";
import memberImages from "@/constants/memberImages";
import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/mockdata/bff-fallback";
import { useGetOrganizations } from "@/api/vcci-hcm/endpoints/organizations";
import type { Organization } from "@/api/vcci-hcm/models";
import Link from "next/link";
import { Autoplay } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import dayjs from "dayjs";
const MEMBER_CONNECTION_FALLBACK_IMAGE = "/home/20-2048x1365.webp";
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;
};
function Members() {
const { memberConnectionPosts, categoryLinks, categoryNames } = useHomePosts();
const connectionPosts = memberConnectionPosts.slice(0, 2);
const sectionLink =
categoryLinks.get(categoryNames.ketNoiHoiVien.toLowerCase()) ?? "/hoi-vien/ket-noi-hoi-vien";
const { data: organizationsResponse, isLoading: featuredMembersLoading } =
useGetOrganizations<Organization[] | undefined>(
{
filters: "users.status_id==36ca1cc5-7b6e-4f9f-b973-69c5207deb62",
pageSize: "12",
sortField: "created_at",
sortOrder: "ASC",
},
{
query: {
staleTime: 60 * 1000,
select: (response) => {
const rows = (response as any)?.responseData?.rows ?? [];
return rows.length > 0 ? rows : MOCK_FEATURED_MEMBER_ROWS;
},
},
},
);
const featuredMembers = organizationsResponse ?? MOCK_FEATURED_MEMBER_ROWS;
const displayMembers = featuredMembers.slice(0, 9);
const renderMemberContent = () => {
if (featuredMembersLoading) {
return (
<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>
);
}
if (displayMembers.length === 0) {
return (
<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>
);
}
return (
<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>
);
};
return (
<section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch">
<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>
{renderMemberContent()}
</aside>
<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="group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[16/10] overflow-hidden xl:aspect-[1.25/1]">
<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.categories[0]?.name && (
<>
<span className="text-[12px] font-medium text-[#f7b500]">
{item.categories[0].name}
</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>
</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="group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[16/10] overflow-hidden">
<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.categories[0]?.name && (
<>
<span className="text-[12px] font-medium text-[#f7b500]">
{item.categories[0].name}
</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>
</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>
</section>
);
}
export default Members;
'use client';
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import type { RawPostWithContent } from "@/api/vcci-news/types/post-raw";
import {
buildPostLink,
normalizeLink,
resolveAssetUrl,
stripHtml,
} from "@/lib/utils/post";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
import dayjs from "dayjs";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
const tabs = [
......@@ -14,36 +22,244 @@ const tabs = [
{ id: "chuyen-de", label: "Chuyên đề" },
];
function News() {
const NEWS_SECTION_LINK_FALLBACK = "/hoat-dong/tin-tuc";
const NEWS_PAGE_SIZE = 6;
const NEWS_CATEGORY_IDS = {
tinVcci: "b89b2ba6-a699-47cb-87e4-0643aea549a9",
tinKinhTe: "755106b6-1aca-47dc-9a9c-d434736c33a1",
chuyenDe: "8e7090e5-bfc3-4128-81a5-37ec78c33bad",
} as const;
const NEWS_CATEGORY_ALIASES = {
tinVcci: ["Tin VCCI", "tin-vcci"],
tinKinhTe: ["Tin Kinh tế", "tin-kinh-te"],
chuyenDe: ["Chuyên đề", "chuyen-de"],
} as const;
type NewsCategoryKey = keyof typeof NEWS_CATEGORY_IDS;
type RawNewsPost = RawPostWithContent & {
thumbnail?: { path?: string | null; original?: string | null } | null;
};
type NewsPostItem = {
id: string;
title: string;
externalLink: string;
summary: string;
contentText: string;
createdAt: string;
publishedAt: string;
categoryName: string;
thumbnail: { url: string; alt: string } | null;
};
const toNewsPostItem = (post: RawNewsPost): NewsPostItem => {
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) : "",
),
summary: String(post.summary ?? post.content ?? ""),
contentText: String(
post.content_structure?.post_content?.[0]?.content ??
post.summary ??
post.content ??
"",
),
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 tương ứng từ chính posts, fallback nếu không có. */
const resolveSectionLink = (
posts: RawNewsPost[],
categoryId: string,
aliases: readonly string[],
) => {
for (const post of posts) {
const category = post.categories?.find(
(item) =>
item?.id === categoryId &&
item?.url &&
item.url !== "#" &&
item.url !== "/",
);
if (category?.url) return normalizeLink(category.url, NEWS_SECTION_LINK_FALLBACK);
}
// Fallback: khớp theo tên/slug alias.
const aliasSlugs = aliases.map((alias) =>
alias
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, ""),
);
for (const post of posts) {
const category = post.categories?.find(
(item) =>
item?.url &&
item.url !== "#" &&
item.url !== "/" &&
aliasSlugs.some((slug) =>
(item.slug ?? "").toLowerCase().includes(slug) ||
item.url!.toLowerCase().includes(slug),
),
);
if (category?.url) return normalizeLink(category.url, NEWS_SECTION_LINK_FALLBACK);
}
return NEWS_SECTION_LINK_FALLBACK;
};
const matchesNewsAliases = (
item: (typeof MOCK_HOME_POSTS)[number],
categoryId: string,
aliases: readonly string[],
) =>
item.categories.some(
(category) =>
category.id === categoryId ||
aliases.some((alias) => category.name.toLowerCase() === alias.toLowerCase()),
);
const toNewsTabResult = (
response: unknown,
categoryId: string,
aliases: readonly string[],
): { posts: NewsPostItem[]; sectionLink: string } => {
const rows =
(response as { responseData?: { rows?: RawNewsPost[] } } | undefined)
?.responseData?.rows ?? [];
if (rows.length === 0) {
// Fallback mock khi BE lỗi hoặc trả rỗng.
const mockPosts = MOCK_HOME_POSTS.filter((item) =>
matchesNewsAliases(item, categoryId, aliases),
).slice(0, NEWS_PAGE_SIZE);
return {
posts: mockPosts.map((item) => ({
id: item.id,
title: item.title,
externalLink: item.externalLink,
summary: item.summary,
contentText: item.contentText,
createdAt: item.createdAt,
publishedAt: item.publishedAt,
categoryName: item.categories[0]?.name ?? "",
thumbnail: item.thumbnail,
})),
sectionLink: NEWS_SECTION_LINK_FALLBACK,
};
}
return {
posts: rows.map(toNewsPostItem),
sectionLink: resolveSectionLink(rows, categoryId, aliases),
};
};
function useNewsTabPosts(key: NewsCategoryKey) {
const categoryId = NEWS_CATEGORY_IDS[key];
const aliases = NEWS_CATEGORY_ALIASES[key];
return useGetApiV10Post(
{
page: 1,
pageSize: NEWS_PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
priorityFeatured: false,
filters: [
`category.id==${categoryId}`,
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
},
{
query: {
staleTime: 60 * 1000,
select: (response: unknown) => toNewsTabResult(response, categoryId, aliases),
},
},
);
}
const uniquePosts = (items: NewsPostItem[]) => {
const seen = new Set<string>();
return items.filter((item) => {
if (!item.id || seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
};
export function News() {
const [tab, setTab] = useState("all");
const { newsTabs, categoryLinks, categoryNames } = useHomePosts();
const tinVcciQuery = useNewsTabPosts("tinVcci");
const tinKinhTeQuery = useNewsTabPosts("tinKinhTe");
const chuyenDeQuery = useNewsTabPosts("chuyenDe");
const tinVcciPosts = tinVcciQuery.data?.posts ?? [];
const tinKinhTePosts = tinKinhTeQuery.data?.posts ?? [];
const chuyenDePosts = chuyenDeQuery.data?.posts ?? [];
const allPosts = useMemo(
() => uniquePosts([...tinVcciPosts, ...tinKinhTePosts, ...chuyenDePosts]),
[tinVcciPosts, tinKinhTePosts, chuyenDePosts],
);
const filteredItems = useMemo(() => {
if (tab === "all") return newsTabs.all;
if (tab === "tin-kinh-te") return newsTabs.tinKinhTe;
if (tab === "chuyen-de") return newsTabs.chuyenDe;
return newsTabs.tinVcci;
}, [newsTabs, tab]);
if (tab === "all") return allPosts;
if (tab === "tin-kinh-te") return tinKinhTePosts;
if (tab === "chuyen-de") return chuyenDePosts;
return tinVcciPosts;
}, [allPosts, tinKinhTePosts, chuyenDePosts, tinVcciPosts, tab]);
const overviewLink = useMemo(() => {
if (tab === "tin-kinh-te") return tinKinhTeQuery.data?.sectionLink ?? NEWS_SECTION_LINK_FALLBACK;
if (tab === "chuyen-de") return chuyenDeQuery.data?.sectionLink ?? NEWS_SECTION_LINK_FALLBACK;
return tinVcciQuery.data?.sectionLink ?? NEWS_SECTION_LINK_FALLBACK;
}, [tinVcciQuery.data, tinKinhTeQuery.data, chuyenDeQuery.data, tab]);
const featuredArticle = filteredItems[0] ?? newsTabs.all[0];
const featuredArticle = filteredItems[0] ?? allPosts[0];
const listArticles = filteredItems.slice(1, 5);
const overviewLink =
(tab === "all"
? categoryLinks.get(categoryNames.tinVcci.toLowerCase())
: tab === "tin-kinh-te"
? categoryLinks.get(categoryNames.tinKinhTe.toLowerCase())
: tab === "chuyen-de"
? categoryLinks.get(categoryNames.chuyenDe.toLowerCase())
: categoryLinks.get(categoryNames.tinVcci.toLowerCase())) ?? "/hoat-dong/tin-tuc";
return (
<div className="flex-1">
<div className="mb-6 flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Tin tức
</h2>
<div className="mt-3 h-[5px] w-[68px] rounded-full bg-[#f7b500]" />
<div className="flex items-center gap-2">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Tin tức
</h2>
<div className="mt-3 h-[5px] w-[68px] rounded-full bg-[#f7b500]" />
</div>
<Link
href="/news"
aria-label="Xem tất cả tin tức"
className="text-[#2b56c0] transition-colors hover:text-[#173f9f]"
>
<ChevronRight className="h-5 w-5" />
</Link>
</div>
<div className="flex flex-wrap items-center gap-3 xl:justify-end">
......@@ -91,7 +307,7 @@ function News() {
className="h-full w-full object-cover"
/>
<span className="absolute left-3 top-3 inline-flex rounded-full bg-[#f7b500] px-3 py-1 text-[12px] font-bold text-[#15357a]">
{featuredArticle.categories[0]?.name || "Tin tức"}
{featuredArticle.categoryName || "Tin tức"}
</span>
</div>
......@@ -101,26 +317,7 @@ function News() {
</h3>
<p className="line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]">
{(() => {
const rawText = featuredArticle.contentText || featuredArticle.summary || "";
const textOnly = rawText
.replace(/\[caption[^\]]*\].*?\[\/caption\]/gi, "")
.replace(/<figure[^>]*>.*?<\/figure>/gi, "")
.replace(/<img[^>]*>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/–/g, "–")
.replace(/\s+/g, " ")
.trim();
return textOnly || "-";
})()}
{stripHtml(featuredArticle.contentText || featuredArticle.summary) || "-"}
</p>
<p className="text-[14px] text-[#8a9bb6]">
......@@ -165,23 +362,7 @@ function News() {
{news.title}
</h4>
{(() => {
const rawText = news.contentText || news.summary || "";
const textOnly = rawText
.replace(/\[caption[^\]]*\].*?\[\/caption\]/gi, "")
.replace(/<figure[^>]*>.*?<\/figure>/gi, "")
.replace(/<img[^>]*>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/–/g, "–")
.replace(/\s+/g, " ")
.trim();
const textOnly = stripHtml(news.contentText || news.summary);
return textOnly.length > 10 ? (
<p className="mt-1 line-clamp-1 text-[13px] leading-normal text-[#6c7b96]">
{textOnly}
......@@ -190,9 +371,9 @@ function News() {
})()}
<div className="mt-1 flex items-center gap-2">
<span className="text-[12px] font-medium text-[#e2a500]">
{news.categories[0]?.name || ""}
{news.categoryName}
</span>
{news.categories[0]?.name && (
{news.categoryName && (
<span className="text-[12px] text-[#8a9bb6]">•</span>
)}
<p className="text-[13px] text-[#8a9bb6]">
......@@ -235,7 +416,7 @@ function News() {
className="h-full w-full object-cover"
/>
<span className="absolute left-3 top-3 inline-flex rounded-full bg-[#f7b500] px-3 py-1 text-[12px] font-bold text-[#15357a]">
{featuredArticle.categories[0]?.name || "Tin tức"}
{featuredArticle.categoryName || "Tin tức"}
</span>
</div>
......@@ -244,23 +425,7 @@ function News() {
{featuredArticle.title}
</h3>
{(() => {
const rawText = featuredArticle.contentText || featuredArticle.summary || "";
const textOnly = rawText
.replace(/\[caption[^\]]*\].*?\[\/caption\]/gi, "")
.replace(/<figure[^>]*>.*?<\/figure>/gi, "")
.replace(/<img[^>]*>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/–/g, "–")
.replace(/\s+/g, " ")
.trim();
const textOnly = stripHtml(featuredArticle.contentText || featuredArticle.summary);
return textOnly.length > 10 ? (
<p className="line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]">
{textOnly}
......@@ -306,23 +471,7 @@ function News() {
{news.title}
</h4>
{(() => {
const rawText = news.contentText || news.summary || "";
const textOnly = rawText
.replace(/\[caption[^\]]*\].*?\[\/caption\]/gi, "")
.replace(/<figure[^>]*>.*?<\/figure>/gi, "")
.replace(/<img[^>]*>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/–/g, "–")
.replace(/\s+/g, " ")
.trim();
const textOnly = stripHtml(news.contentText || news.summary);
return textOnly.length > 10 ? (
<p className="mt-1 line-clamp-1 text-[13px] leading-normal text-[#6c7b96]">
{textOnly}
......@@ -330,10 +479,10 @@ function News() {
) : null;
})()}
<div className="mt-1 flex items-center gap-2">
{news.categories[0]?.name && (
{news.categoryName && (
<>
<span className="text-[12px] font-medium text-[#e2a500]">
{news.categories[0].name}
{news.categoryName}
</span>
<span className="text-[12px] text-[#8a9bb6]">•</span>
</>
......@@ -359,17 +508,6 @@ function News() {
})}
</div>
</div>
{/* <div className="mt-4 hidden justify-end xl:flex">
<Link
href={overviewLink}
className="text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
Xem tất cả
</Link>
</div> */}
</div>
);
}
export default News;
}
\ No newline at end of file
"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 ?? []
);
}
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
import { getApiV10Post, useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import Links from "@/links";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
type RawHomeCategory = {
id?: string | null;
name?: string | null;
slug?: string | null;
url?: string | null;
type?: string | null;
};
type RawHomeThumbnail = {
path?: string | null;
original?: string | null;
};
type RawHomePost = {
id?: string | null;
title?: string | null;
slug?: string | null;
summary?: string | null;
content?: string | null;
release_at?: string | null;
published_at?: string | null;
created_at?: string | null;
started_at?: string | null;
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;
categories?: RawHomeCategory[] | null;
thumbnail?: RawHomeThumbnail | null;
content_structure?: {
post_content?: Array<{
content?: string | null;
}> | null;
} | null;
};
type HomeEnvelope<T> = {
responseData?: T;
};
type HomePagedResult<T> = {
rows?: T[];
};
export type HomePostCategory = {
id: string;
name: string;
slug: string;
url: string;
type: string;
};
export type HomePostItem = {
id: string;
title: string;
externalLink: string;
summary: string;
contentText: string;
createdAt: string;
publishedAt: string;
startedAt: string;
endedAt: string;
registrationDeadline: string;
location: string;
participationFee: string;
expiredAt: string;
isFeatured: boolean;
isHidden: boolean;
isActive: boolean;
status: string;
type: string;
eventDates?: string[];
categories: HomePostCategory[];
thumbnail: {
url: string;
alt: string;
} | null;
};
const HOME_POSTS_QUERY_KEY = ["home-page-posts", "featured-page-size-3"] as const;
const HOME_CATEGORY_NAMES = {
tinVcci: "Tin VCCI",
tinKinhTe: "Tin Kinh tế",
chuyenDe: "Chuyên đề",
suKien: "Sự kiện",
daoTao: "Đào tạo",
coHoiKinhDoanh: "Cơ hội kinh doanh",
chinhSachPhapLuat: "Thông tin Chính sách và Pháp luật",
ketNoiHoiVien: "Kết nối hội viên",
} as const;
const normalizeText = (value?: string | null) => value?.trim().toLowerCase() ?? "";
const normalizeSearchText = (value?: string | null) =>
normalizeText(value)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/&/g, " va ")
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, " ");
const normalizeSlug = (value?: string | null) =>
normalizeSearchText(value).replace(/\s+/g, "-");
const HOME_CATEGORY_ALIASES = {
tinVcci: ["Tin VCCI", "tin-vcci"],
tinKinhTe: ["Tin Kinh tế", "tin-kinh-te"],
chuyenDe: ["Chuyên đề", "chuyen-de"],
suKien: ["Sự kiện", "su-kien"],
daoTao: ["Đào tạo", "dao-tao"],
coHoiKinhDoanh: ["Cơ hội kinh doanh", "co-hoi-kinh-doanh"],
chinhSachPhapLuat: [
"Thông tin Chính sách và Pháp luật",
"Chính sách và Pháp luật",
"Chính sách & Pháp luật",
"Chính sách Pháp luật",
"thong-tin-chinh-sach-va-phap-luat",
"chinh-sach-va-phap-luat",
"chinh-sach-phap-luat",
],
ketNoiHoiVien: ["Kết nối hội viên", "ket-noi-hoi-vien"],
} as const;
const HOME_CATEGORY_IDS = {
tinVcci: "b89b2ba6-a699-47cb-87e4-0643aea549a9",
tinKinhTe: "755106b6-1aca-47dc-9a9c-d434736c33a1",
chuyenDe: "8e7090e5-bfc3-4128-81a5-37ec78c33bad",
suKien: "b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a",
daoTao: "36df7021-9a74-43d6-9084-0d5ed347b7f4",
coHoiKinhDoanh: "0a460499-89c1-4f52-8592-1fb7bb69c4a2",
ketNoiHoiVien: "a37b8a02-e8b3-42ce-9225-6dae460fed99",
chinhSachPhapLuat: "cc448be9-b9ea-46a8-aa7b-0584803330e8",
lienKetNhanh: "d7f05384-b1b4-428e-b9b3-37e0e1b0cecd",
} as const;
const normalizeLink = (value?: string | null, fallback = "/") => {
const trimmed = value?.trim();
if (!trimmed) return fallback;
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed;
if (trimmed.startsWith("/")) return trimmed;
return `/${trimmed}`;
};
const buildPostLink = (path?: string | null, id?: string | null, fallback = "#") => {
const normalizedPath = normalizeLink(path, fallback);
const trimmedId = id?.trim() ?? "";
if (!trimmedId || normalizedPath === "#") return normalizedPath;
const params = new URLSearchParams({ id: trimmedId });
return `${normalizedPath}?${params.toString()}`;
};
const resolveAssetUrl = (value?: string | null) => {
const trimmed = value?.trim();
if (!trimmed) return "/thumbnail.png";
return Links.resolveImageUrl(trimmed);
};
const sortByPublishedDesc = (items: HomePostItem[]) =>
[...items].sort((left, right) => {
const leftTime = new Date(left.publishedAt || left.createdAt).getTime();
const rightTime = new Date(right.publishedAt || right.createdAt).getTime();
return rightTime - leftTime;
});
const sortByEventStartAsc = (items: HomePostItem[]) =>
[...items].sort((left, right) => {
const leftTime = new Date(left.startedAt || left.publishedAt || left.createdAt).getTime();
const rightTime = new Date(right.startedAt || right.publishedAt || right.createdAt).getTime();
return leftTime - rightTime;
});
const uniquePosts = (items: HomePostItem[]) => {
const seen = new Set<string>();
return items.filter((item) => {
if (!item.id || seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
};
const matchesCategoryName = (item: HomePostItem, categoryName: string) => {
const normalizedTarget = normalizeText(categoryName);
return item.categories.some(
(category) => normalizeText(category.name) === normalizedTarget,
);
};
const matchesCategoryAliases = (
item: HomePostItem,
aliases: readonly string[],
) => {
const aliasKeys = new Set(aliases.map(normalizeSearchText));
const aliasSlugs = new Set(aliases.map(normalizeSlug));
return item.categories.some((category) => {
const categoryNameKey = normalizeSearchText(category.name);
const categorySlugKey = normalizeSlug(category.slug || category.name);
const categoryUrlKey = normalizeSlug(category.url);
const categoryUrlSegments = category.url
.split("/")
.map((segment) => normalizeSlug(segment))
.filter(Boolean);
return (
aliasKeys.has(categoryNameKey) ||
aliasSlugs.has(categorySlugKey) ||
categoryUrlSegments.some((segment) => aliasSlugs.has(segment)) ||
Array.from(aliasSlugs).some((slug) => categoryUrlKey.endsWith(slug))
);
});
};
const isVisibleNewsPost = (item: HomePostItem) => {
if (item.type && item.type !== "news") return false;
if (item.isHidden) return false;
if (!item.isActive) return false;
return true;
};
const hasCategoryId = (item: HomePostItem, categoryId: string) =>
item.categories.some((category) => category.id === categoryId);
async function fetchHomePostRows(params: {
page?: number;
pageSize?: number;
sortField?: string;
sortOrder?: string;
filters?: string;
}) {
const response = await getApiV10Post({
page: params.page ?? 1,
pageSize: params.pageSize ?? 10,
sortField: params.sortField ?? "release_at",
sortOrder: (params.sortOrder ?? "desc") as "asc" | "desc",
filters: params.filters,
});
return ((response.responseData?.rows ?? []) as unknown as RawHomePost[]);
}
function createCategoryPostsParams(categoryId: string, pageSize: string) {
return {
page: 1,
pageSize: Number(pageSize),
sortField: "release_at",
sortOrder: "desc" as const,
filters: [
`category.id==${categoryId}`,
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
};
}
function createMultiCategoryPostsParams(categoryIds: string[], pageSize: string) {
return {
page: 1,
pageSize: Number(pageSize),
sortField: "release_at",
sortOrder: "desc" as const,
filters: [
`category.id==(${categoryIds.join("|")})`,
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
};
}
function createEventCalendarParams(currentMonth: Date) {
return {
page: 1,
pageSize: 100,
sortField: "started_at",
sortOrder: "asc" as const,
filters: [
`category.id==(${HOME_CATEGORY_IDS.suKien}|${HOME_CATEGORY_IDS.daoTao})`,
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
};
}
async function fetchHomePosts() {
// Trả về mock ngay khi CMS/BE gặp sự cố — website vẫn hiển thị được nội dung cơ bản.
try {
return await fetchHomePostsFromApi();
} catch (error) {
// eslint-disable-next-line no-console
console.warn("[useHomePosts] CMS unavailable, falling back to mock data", error);
return MOCK_HOME_POSTS;
}
}
async function fetchHomePostsFromApi() {
const featuredParams = {
page: 1,
pageSize: 3,
sortField: "release_at",
sortOrder: "desc" as const,
filters: [
"is_featured==true",
"is_hidden==false",
"is_active==true",
"type==news",
].join(","),
};
const tinVcciParams = createCategoryPostsParams(HOME_CATEGORY_IDS.tinVcci, "6");
const tinKinhTeParams = createCategoryPostsParams(HOME_CATEGORY_IDS.tinKinhTe, "6");
const chuyenDeParams = createCategoryPostsParams(HOME_CATEGORY_IDS.chuyenDe, "6");
const eventParams = createMultiCategoryPostsParams(
[HOME_CATEGORY_IDS.suKien, HOME_CATEGORY_IDS.daoTao],
"5"
);
const policyParams = createCategoryPostsParams(HOME_CATEGORY_IDS.chinhSachPhapLuat, "6");
const quickLinksParams = createCategoryPostsParams(HOME_CATEGORY_IDS.lienKetNhanh, "6");
const trainingParams = createCategoryPostsParams(HOME_CATEGORY_IDS.daoTao, "10");
const businessParams = createCategoryPostsParams(HOME_CATEGORY_IDS.coHoiKinhDoanh, "10");
const memberConnectionParams = createCategoryPostsParams(HOME_CATEGORY_IDS.ketNoiHoiVien, "10");
const [
featuredRows,
tinVcciRows,
tinKinhTeRows,
chuyenDeRows,
policyRows,
eventRows,
quickLinkRows,
trainingRows,
businessRows,
memberConnectionRows,
] = await Promise.all([
fetchHomePostRows(featuredParams),
fetchHomePostRows(tinVcciParams),
fetchHomePostRows(tinKinhTeParams),
fetchHomePostRows(chuyenDeParams),
fetchHomePostRows(policyParams),
fetchHomePostRows(eventParams),
fetchHomePostRows(quickLinksParams),
fetchHomePostRows(trainingParams),
fetchHomePostRows(businessParams),
fetchHomePostRows(memberConnectionParams),
]);
const rows = [
...featuredRows,
...tinVcciRows,
...tinKinhTeRows,
...chuyenDeRows,
...policyRows,
...eventRows,
...trainingRows,
...quickLinkRows,
...businessRows,
...memberConnectionRows,
];
return rows
.map<HomePostItem>((item) => {
const categories = (item.categories ?? [])
.filter((category) => category?.id && category?.name)
.map((category) => ({
id: String(category.id),
name: String(category.name),
slug: String(category.slug ?? ""),
url: normalizeLink(category.url, "#"),
type: String(category.type ?? ""),
}));
const thumbnailPath = item.thumbnail?.path ?? item.thumbnail?.original ?? null;
const title = String(item.title ?? "").trim();
const externalLink = buildPostLink(
item.slug ? `/${item.slug}` : (title ? `/${title}` : undefined),
item.id ? String(item.id) : "",
"#",
);
return {
id: String(item.id ?? ""),
title,
externalLink,
summary: String(item.summary ?? item.content ?? ""),
contentText: String(
item.content_structure?.post_content?.[0]?.content ??
item.summary ??
item.content ??
""
),
createdAt: String(item.created_at ?? ""),
publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""),
startedAt: String(item.started_at ?? ""),
endedAt: String(item.ended_at ?? ""),
registrationDeadline: String(item.registration_deadline ?? ""),
location: String(item.location ?? ""),
participationFee: String(item.participation_fee ?? ""),
expiredAt: String(item.expired_at ?? ""),
isFeatured: Boolean(item.is_featured),
isHidden: Boolean(item.is_hidden),
isActive: item.is_active !== false,
status: String(item.status ?? ""),
type: String(item.type ?? ""),
eventDates: Array.isArray(item.event_dates)
? item.event_dates.filter((d): d is string => typeof d === "string")
: [],
categories,
thumbnail: thumbnailPath
? {
url: resolveAssetUrl(thumbnailPath),
alt: title,
}
: null,
};
})
.filter((item) => item.id && item.title);
}
export function useHomePosts() {
const query = useQuery({
queryKey: HOME_POSTS_QUERY_KEY,
queryFn: fetchHomePosts,
staleTime: 5 * 60 * 1000,
});
const allPosts = React.useMemo(
() => uniquePosts(query.data ?? []),
[query.data],
);
const posts = React.useMemo(
() => allPosts.filter(isVisibleNewsPost),
[allPosts],
);
const categoryLinks = React.useMemo(() => {
const entries = posts.flatMap((item) => item.categories);
const map = new Map<string, string>();
entries.forEach((category) => {
const key = normalizeText(category.name);
if (!key || map.has(key) || !category.url || category.url === "#") return;
map.set(key, category.url);
});
return map;
}, [posts]);
const tinVcciPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.tinVcci),
),
),
[posts],
);
const tinKinhTePosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.tinKinhTe),
),
),
[posts],
);
const chuyenDePosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.chuyenDe),
),
),
[posts],
);
const featuredPosts = React.useMemo(
() =>
sortByPublishedDesc(
allPosts.filter((item) => item.isFeatured && !item.isHidden),
),
[allPosts],
);
const eventPosts = React.useMemo(
() =>
sortByEventStartAsc(
posts.filter(
(item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.suKien),
),
),
[posts],
);
const eventCalendarPosts = React.useMemo(
() =>
sortByEventStartAsc(
posts.filter(
(item) =>
Boolean(item.registrationDeadline) &&
(matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.suKien) ||
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.daoTao)),
),
),
[posts],
);
const businessPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.coHoiKinhDoanh),
),
),
[posts],
);
const policyPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.chinhSachPhapLuat),
),
),
[posts],
);
const memberConnectionPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.ketNoiHoiVien),
),
),
[posts],
);
const quickLinkPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => hasCategoryId(item, HOME_CATEGORY_IDS.lienKetNhanh)),
),
[posts],
);
const allNewsPosts = React.useMemo(
() => uniquePosts([...tinVcciPosts, ...tinKinhTePosts, ...chuyenDePosts]),
[chuyenDePosts, tinKinhTePosts, tinVcciPosts],
);
return {
...query,
allPosts,
posts,
featuredPosts,
eventPosts,
eventCalendarPosts,
businessPosts,
policyPosts,
memberConnectionPosts,
quickLinkPosts,
newsTabs: {
all: allNewsPosts,
tinVcci: tinVcciPosts,
tinKinhTe: tinKinhTePosts,
chuyenDe: chuyenDePosts,
},
categoryLinks,
categoryNames: HOME_CATEGORY_NAMES,
};
}
export function useEventCalendarPosts(currentMonth: Date) {
const params = createEventCalendarParams(currentMonth);
const monthStart = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1);
const monthEnd = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0, 23, 59, 59, 999);
const query = useGetApiV10Post<HomePostItem[]>(
params,
{
query: {
staleTime: 5 * 60 * 1000,
select: (response): HomePostItem[] => {
const mappedPosts = ((response?.responseData?.rows ?? []) as unknown as RawHomePost[]).map((item) => {
const categories = (item.categories ?? [])
.filter((category) => category?.id && category?.name)
.map((category) => ({
id: String(category.id),
name: String(category.name),
slug: String(category.slug ?? ""),
url: normalizeLink(category.url, "#"),
type: String(category.type ?? ""),
}));
const thumbnailPath = item.thumbnail?.path ?? item.thumbnail?.original ?? null;
const title = String(item.title ?? "").trim();
const externalLink = buildPostLink(
item.slug ? `/${item.slug}` : (title ? `/${title}` : undefined),
item.id ? String(item.id) : "",
"#",
);
return {
id: String(item.id ?? ""),
title,
externalLink,
summary: String(item.summary ?? item.content ?? ""),
contentText: String(
item.content_structure?.post_content?.[0]?.content ??
item.summary ??
item.content ??
""
),
createdAt: String(item.created_at ?? ""),
publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""),
startedAt: String(item.started_at ?? ""),
endedAt: String(item.ended_at ?? ""),
registrationDeadline: String(item.registration_deadline ?? ""),
location: String(item.location ?? ""),
participationFee: String(item.participation_fee ?? ""),
expiredAt: String(item.expired_at ?? ""),
isFeatured: Boolean(item.is_featured),
isHidden: Boolean(item.is_hidden),
isActive: item.is_active !== false,
status: String(item.status ?? ""),
type: String(item.type ?? ""),
eventDates: Array.isArray(item.event_dates)
? item.event_dates.filter((d): d is string => typeof d === "string")
: [],
categories,
thumbnail: thumbnailPath
? {
url: resolveAssetUrl(thumbnailPath),
alt: title,
}
: null,
} satisfies HomePostItem;
});
return mappedPosts.filter((item) => {
const startedAt = item.startedAt ? dayjs(item.startedAt) : null;
const endedAt = item.endedAt ? dayjs(item.endedAt) : null;
const registrationDeadline = item.registrationDeadline ? dayjs(item.registrationDeadline) : null;
const eventDates = (item.eventDates ?? []).map((d) => dayjs(d)).filter((d) => d.isValid());
if (!startedAt && !endedAt && !registrationDeadline && eventDates.length === 0) return false;
const monthStartDay = dayjs(monthStart);
const monthEndDay = dayjs(monthEnd);
const hasDateInMonth = (date: dayjs.Dayjs | null): boolean => {
return date !== null && !date.isBefore(monthStartDay, "day") && !date.isAfter(monthEndDay, "day");
};
if (eventDates.length > 0) {
return eventDates.some((d) => hasDateInMonth(d));
}
const eventStartDate = startedAt || registrationDeadline;
const eventEndDate = endedAt || registrationDeadline || startedAt;
if (eventStartDate && eventEndDate) {
return !eventStartDate.isAfter(monthEndDay, "day") && !eventEndDate.isBefore(monthStartDay, "day");
}
if (startedAt && hasDateInMonth(startedAt)) return true;
if (endedAt && hasDateInMonth(endedAt)) return true;
if (registrationDeadline && hasDateInMonth(registrationDeadline)) return true;
return false;
});
},
},
},
);
return query;
}
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">
<FeaturedNews />
<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" >
<Events />
<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">
<BusinessOpportunities />
<PolicyAndLaws />
<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({
page,
pageSize,
sortField: "release_at",
sortOrder: "desc",
filters: filters || undefined,
});
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][])
.map(mapPost)
.filter((item: DynamicPostItem) => item.id && item.title);
useEffect(() => {
const nextPage = pageFromUrl ? Number(pageFromUrl) : 1;
if (Number.isFinite(nextPage)) {
setPage(nextPage);
}
}, [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 });
};
let rows: DynamicPostItem[] = [];
let count = 0;
let pageSize = PAGE_SIZE;
let currentPage = page;
try {
const response = (await getApiV10Post({
page,
pageSize: PAGE_SIZE,
sortField: "release_at",
sortOrder: "desc",
filters,
})) as {
responseData?: {
count?: number;
page?: number;
pageSize?: number;
rows?: unknown[];
};
};
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) => item.id && item.title);
} catch {
rows = [];
}
const totalPages = Math.max(1, Math.ceil(count / pageSize));
return (
<div className="min-h-screen bg-white">
......@@ -154,91 +150,36 @@ 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) => (
<SearchResultItem key={item.id} item={item} index={index} />
))
) : (
<div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600">
Không tìm thấy bài viết phù hợp.
</div>
)}
<div className="flex w-full justify-center pt-2">
<Pagination
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);
}}
/>
<div className="space-y-9">
{rows.length ? (
rows.map((item, index) => (
<SearchResultItem key={item.id} item={item} index={index} />
))
) : (
<div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600">
Không tìm thấy bài viết phù hợp.
</div>
</div>
)}
)}
<PaginationNav
page={Math.min(currentPage, totalPages)}
pageCount={totalPages}
basePath="/search"
keepParams={[{ key: "q", value: query }]}
/>
</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} />;
}
import { Play } from "lucide-react";
import Image from "next/image";
import { getApiV10Video } from "@/api/vcci-news/endpoints/video";
import type { Video } from "@/api/vcci-news/models/video";
import PaginationNav from "@/components/base/pagination-nav";
import { getVideoThumbnail, normalizeVideoUrl } from "@/lib/utils/video";
const PAGE_SIZE = 10;
type VideoListProps = {
searchParams: Promise<{ page?: string }>;
};
export default async function VideoListing({ searchParams }: VideoListProps) {
const { page: pageParam } = await searchParams;
const pageFromUrl = Number(pageParam ?? "1");
const page =
Number.isFinite(pageFromUrl) && pageFromUrl > 0 ? Math.floor(pageFromUrl) : 1;
let videos: Video[] = [];
let count = 0;
let pageSize = PAGE_SIZE;
let currentPage = page;
try {
const response = await getApiV10Video({
page,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
});
const pageData = (response as {
responseData?: {
count?: number;
page?: number;
pageSize?: number;
rows?: Video[];
};
} | undefined)?.responseData;
count = Number(pageData?.count ?? 0);
pageSize = Number(pageData?.pageSize ?? PAGE_SIZE) || PAGE_SIZE;
currentPage = Number(pageData?.page ?? page) || page;
videos = pageData?.rows ?? [];
} catch {
videos = [];
}
const totalPages = Math.max(1, Math.ceil(count / pageSize));
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>
{videos.length ? (
<div className="space-y-8">
<div className="grid gap-6 md:grid-cols-2">
{videos.map((video) => {
const url = video.url ?? "";
const thumbnail = getVideoThumbnail(url);
const watchUrl = normalizeVideoUrl(url);
return (
<a
key={video.id}
href={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={thumbnail}
alt={video.name ?? "Video"}
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 ? (
<PaginationNav
page={currentPage}
pageCount={totalPages}
basePath="/video"
/>
) : 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>
);
}
......@@ -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);
......@@ -393,6 +396,4 @@ function EventsCalendar({
`}</style>
</aside>
);
}
export default EventsCalendar;
}
\ No newline at end of file
"use client";
import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
import { getApiV10Post } from "@/api/vcci-news/endpoints/post";
import type { RawEventCalendarPost } from "@/api/vcci-news/types/post-raw";
import type { PagedResult } from "@/api/vcci-news/types/paged-result";
import type { GetApiV10PostParams } from "@/api/vcci-news/models/getApiV10PostParams";
import {
buildPostLink,
normalizeLink,
resolveAssetUrl,
} from "@/lib/utils/post";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
import { useMemo } from "react";
type RawHomePost = RawEventCalendarPost;
export type EventCalendarCategory = {
id: string;
name: string;
slug: string;
url: string;
type: string;
};
export type EventCalendarItem = {
id: string;
title: string;
externalLink: string;
summary: string;
contentText: string;
createdAt: string;
publishedAt: string;
startedAt: string;
endedAt: string;
registrationDeadline: string;
location: string;
participationFee: string;
expiredAt: string;
isFeatured: boolean;
isHidden: boolean;
isActive: boolean;
status: string;
type: string;
eventDates?: string[];
categories: EventCalendarCategory[];
thumbnail: {
url: string;
alt: string;
} | null;
};
const EVENT_CATEGORY_IDS = {
suKien: "b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a",
daoTao: "36df7021-9a74-43d6-9084-0d5ed347b7f4",
} as const;
const mapRawPostToEventCalendarItem = (item: RawHomePost): EventCalendarItem => {
const categories = (item.categories ?? [])
.filter((category) => category?.id && category?.name)
.map((category) => ({
id: String(category.id),
name: String(category.name),
slug: String(category.slug ?? ""),
url: normalizeLink(category.url, "#"),
type: String(category.type ?? ""),
}));
const thumbnailPath = item.thumbnail?.path ?? item.thumbnail?.original ?? null;
const title = String(item.title ?? "").trim();
const externalLink = buildPostLink(
item.slug ? `/${item.slug}` : (title ? `/${title}` : undefined),
item.id ? String(item.id) : "",
"#",
);
return {
id: String(item.id ?? ""),
title,
externalLink,
summary: String(item.summary ?? item.content ?? ""),
contentText: String(
item.content_structure?.post_content?.[0]?.content ??
item.summary ??
item.content ??
""
),
createdAt: String(item.created_at ?? ""),
publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""),
startedAt: String(item.started_at ?? ""),
endedAt: String(item.ended_at ?? ""),
registrationDeadline: String(item.registration_deadline ?? ""),
location: String(item.location ?? ""),
participationFee: String(item.participation_fee ?? ""),
expiredAt: String(item.expired_at ?? ""),
isFeatured: Boolean(item.is_featured),
isHidden: Boolean(item.is_hidden),
isActive: item.is_active !== false,
status: String(item.status ?? ""),
type: String(item.type ?? ""),
eventDates: Array.isArray(item.event_dates)
? item.event_dates.filter((d): d is string => typeof d === "string")
: [],
categories,
thumbnail: thumbnailPath
? {
url: resolveAssetUrl(thumbnailPath),
alt: title,
}
: null,
};
};
async function fetchEventCalendarRows(params: GetApiV10PostParams) {
const response = await getApiV10Post(params);
const rows =
(response as PagedResult<RawHomePost> | undefined)?.responseData?.rows ?? [];
return rows.map<EventCalendarItem>(mapRawPostToEventCalendarItem);
}
/**
* Params lịch sự kiện theo tháng: filter theo tháng được thực hiện hoàn toàn
* ở BE. Toán tử `[]` = date between; đặt `registration_deadline` cuối cùng
* (trước `[]`) để BE nhận diện đây là monthly-event query (pageSize=0 => trả
* toàn bộ, không giới hạn). Cú pháp `field1|field2|field3[](start|end)` tạo
* điều kiện OR: event rơi vào tháng khi registration_deadline / started_at /
* ended_at nằm trong khoảng tháng đó.
*/
function createEventCalendarParams(currentMonth: Date): GetApiV10PostParams {
const monthStart = dayjs(currentMonth).startOf("month").format("YYYY-MM-DD");
const monthEnd = dayjs(currentMonth).endOf("month").format("YYYY-MM-DDTHH:mm:ss");
return {
page: 1,
pageSize: 0,
sortField: "started_at",
sortOrder: "asc",
priorityFeatured: false,
filters: [
`category.id==(${EVENT_CATEGORY_IDS.suKien}|${EVENT_CATEGORY_IDS.daoTao})`,
"is_hidden==false",
"is_active==true",
"type==event",
`registration_deadline|started_at|ended_at[](${monthStart}|${monthEnd})`,
].join(","),
};
}
export function useEventCalendarPosts(currentMonth: Date) {
const params = useMemo(
() => createEventCalendarParams(currentMonth),
[currentMonth],
);
const query = useQuery({
queryKey: ["event-calendar-posts", params] as const,
queryFn: async () => {
try {
return await fetchEventCalendarRows(params);
} catch (error) {
// eslint-disable-next-line no-console
console.warn("[useEventCalendarPosts] CMS unavailable, falling back to mock data", error);
return MOCK_HOME_POSTS.filter((item) => Boolean(item.registrationDeadline));
}
},
staleTime: 5 * 60 * 1000,
});
return query;
}
"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
'use client';
"use client";
import { useMemo } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import { useQuery } from "@tanstack/react-query";
import { getApiV10AdvertisementPublic } from "@/api/vcci-news/endpoints/advertisement";
import type { Advertisement } from "@/api/vcci-news/models/advertisement";
import { getFallbackImage } from "@/lib/utils/fallback-image";
import { SidebarAdItem } from "./components/sidebar-ad-item";
import { FallbackSidebarAdItem } from "./components/fallback-sidebar-ad-item";
/** 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("[SidebarAdvertisements] CMS unavailable, using fallback", error);
return [];
}
};
function SidebarAdvertisements({ count = 5, startIndex = 0 }: { count?: number; startIndex?: number }) {
const ads = useAdvertisements("square");
const { data: ads = [] } = useQuery({
queryKey: ["sidebar-advertisements", "square"] as const,
queryFn: fetchSquareAds,
staleTime: 60 * 1000,
});
const visibleAds = ads.slice(startIndex, startIndex + count);
const fallbackSrcs = useMemo(
......
// Query response type
interface ResponseType<T = any> {
data: any
message: string | null
message_en: string | null
responseData: T
status: 'fail' | 'success'
statusCode: number
timeStamp: string
violations: Array<{
code: number
message: string
action: Array<{
location: string
msg: {
en: string
vi: string
}
path: string
value: string
}>
}> | null
}
// Infinite query statuses type
interface InfiniteQueryStatusesType {
isFetchingInitialPage: boolean
isFetchingNextPage: boolean
isEmptyData: boolean
isError?: boolean
}
export type { ResponseType, InfiniteQueryStatusesType }
export type GoogleRedirectResponse<Flow = AuthorizationCodeFlow | ImplicitGrantFlow> =
| GoogleResponseSuccess<Flow>
| GoogleResponseError
export type AuthorizationCodeFlow = { code: string }
export type ImplicitGrantFlow = { access_token: string; token_type: string; expires_in: string }
export type GoogleResponseSuccess<Flow = AuthorizationCodeFlow | ImplicitGrantFlow> = {
state: string
scope: string
authuser: string
prompt: string
} & Flow
export type GoogleResponseError = { error: string }
export type GoogleRedirectResponseQueryKeys =
| keyof GoogleResponseSuccess<AuthorizationCodeFlow>
| keyof GoogleResponseSuccess<ImplicitGrantFlow>
| keyof GoogleResponseError
export interface GenerateAuthUrlOpts {
/**
* Recommended. Indicates whether your application can refresh access tokens
* when the user is not present at the browser. Valid parameter values are
* 'online', which is the default value, and 'offline'. Set the value to
* 'offline' if your application needs to refresh access tokens when the user
* is not present at the browser. This value instructs the Google
* authorization server to return a refresh token and an access token the
* first time that your application exchanges an authorization code for
* tokens.
*/
access_type?: string
/**
* The hd (hosted domain) parameter streamlines the login process for G Suite
* hosted accounts. By including the domain of the G Suite user (for example,
* mycollege.edu), you can indicate that the account selection UI should be
* optimized for accounts at that domain. To optimize for G Suite accounts
* generally instead of just one domain, use an asterisk: hd=*.
* Don't rely on this UI optimization to control who can access your app,
* as client-side requests can be modified. Be sure to validate that the
* returned ID token has an hd claim value that matches what you expect
* (e.g. mycolledge.edu). Unlike the request parameter, the ID token claim is
* contained within a security token from Google, so the value can be trusted.
*/
hd?: string
/**
* The 'response_type' will always be set to 'CODE'.
*/
response_type?: string
/**
* The client ID for your application. The value passed into the constructor
* will be used if not provided. You can find this value in the API Console.
*/
client_id?: string
/**
* Determines where the API server redirects the user after the user
* completes the authorization flow. The value must exactly match one of the
* 'redirect_uri' values listed for your project in the API Console. Note that
* the http or https scheme, case, and trailing slash ('/') must all match.
* The value passed into the constructor will be used if not provided.
*/
redirect_uri?: string
/**
* Required. A space-delimited list of scopes that identify the resources that
* your application could access on the user's behalf. These values inform the
* consent screen that Google displays to the user. Scopes enable your
* application to only request access to the resources that it needs while
* also enabling users to control the amount of access that they grant to your
* application. Thus, there is an inverse relationship between the number of
* scopes requested and the likelihood of obtaining user consent. The
* OAuth 2.0 API Scopes document provides a full list of scopes that you might
* use to access Google APIs. We recommend that your application request
* access to authorization scopes in context whenever possible. By requesting
* access to user data in context, via incremental authorization, you help
* users to more easily understand why your application needs the access it is
* requesting.
*/
scope?: string[] | string
/**
* Recommended. Specifies any string value that your application uses to
* maintain state between your authorization request and the authorization
* server's response. The server returns the exact value that you send as a
* name=value pair in the hash (#) fragment of the 'redirect_uri' after the
* user consents to or denies your application's access request. You can use
* this parameter for several purposes, such as directing the user to the
* correct resource in your application, sending nonces, and mitigating
* cross-site request forgery. Since your redirect_uri can be guessed, using a
* state value can increase your assurance that an incoming connection is the
* result of an authentication request. If you generate a random string or
* encode the hash of a cookie or another value that captures the client's
* state, you can validate the response to additionally ensure that the
* request and response originated in the same browser, providing protection
* against attacks such as cross-site request forgery. See the OpenID Connect
* documentation for an example of how to create and confirm a state token.
*/
state?: string
/**
* Optional. Enables applications to use incremental authorization to request
* access to additional scopes in context. If you set this parameter's value
* to true and the authorization request is granted, then the new access token
* will also cover any scopes to which the user previously granted the
* application access. See the incremental authorization section for examples.
*/
include_granted_scopes?: boolean
/**
* Optional. If your application knows which user is trying to authenticate,
* it can use this parameter to provide a hint to the Google Authentication
* Server. The server uses the hint to simplify the login flow either by
* prefilling the email field in the sign-in form or by selecting the
* appropriate multi-login session. Set the parameter value to an email
* address or sub identifier, which is equivalent to the user's Google ID.
*/
login_hint?: string
/**
* Optional. A space-delimited, case-sensitive list of prompts to present the
* user. If you don't specify this parameter, the user will be prompted only
* the first time your app requests access. Possible values are:
*
* 'none' - Donot display any authentication or consent screens. Must not be
* specified with other values.
* 'consent' - Prompt the user for consent.
* 'select_account' - Prompt the user to select an account.
*/
prompt?: string
}
export interface NewsDetailItem {
id: string
title: string
thumbnail: string
description: string
release_at: string
is_active: boolean
created_at: string
created_by: string | null
updated_at: string
updated_by: string | null
mode: 'NOW' | string
category: string
}
export interface NewsDetailResponseData {
count: number
rows: NewsDetailItem[]
totalPages: number
currentPage: number
}
export interface GetNewsDetailResponseType {
message: string
message_en: string
responseData: NewsDetailItem
status: 'success' | 'error'
timeStamp: string
violations: any | null
}
\ No newline at end of file
export interface SearchParams<Key extends string = string> extends URLSearchParams {
get: (name: Key) => string | null
}
// Core
import { twMerge } from 'tailwind-merge'
import { clsx, type ClassValue } from 'clsx'
// Cn
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
import { Buffer } from 'buffer'
export async function encrypt(dataToEncrypt: string, keyString: string, bufferString: string) {
const ivBuffer = Buffer.from(bufferString, 'base64')
const key = await getKey(keyString)
const dataBuffer = new TextEncoder().encode(dataToEncrypt)
const encryptedData = await encryptData(dataBuffer, key, ivBuffer)
return Buffer.from(encryptedData).toString('base64')
}
export async function decrypt(encryptedDataString: string, keyString: string, bufferString: string) {
const key = await getKey(keyString)
const encryptedBuffer = Buffer.from(encryptedDataString, 'base64')
const ivBuffer = Buffer.from(bufferString, 'base64')
const decryptedBuffer = await decryptData(encryptedBuffer, key, ivBuffer)
return new TextDecoder().decode(decryptedBuffer)
}
// ======================================= HELPER FUNCTIONS ================================
const getKey = async (encryptionKey: string) => {
return await crypto.subtle.importKey(
'raw',
Buffer.from(encryptionKey),
{
name: 'AES-GCM',
length: 256
},
true,
['encrypt', 'decrypt']
)
}
const encryptData = async (dataBuffer: BufferSource, key: CryptoKey, ivBuffer: BufferSource) => {
return await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: ivBuffer
},
key,
dataBuffer
)
}
const decryptData = async (encryptedDataBuffer: BufferSource, key: CryptoKey, ivBuffer: BufferSource) => {
return await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: ivBuffer
},
key,
encryptedDataBuffer
)
}
// import { z } from 'zod'
// const envConfigSchema = z.object({
// BUFFER_KEY: z.string().min(1),
// SECRET_KEY: z.string().min(1)
// })
// Process env is for nextjs, import meta for vite
// const envConfigParser = envConfigSchema.safeParse({
// BUFFER_KEY: typeof process != 'undefined' ? process.env.FOSCO_BUFFER : import.meta.env.FOSCO_BUFFER,
// SECRET_KEY: typeof process != 'undefined' ? process.env.FOSCO_SECRET : import.meta.env.FOSCO_SECRET
// })
// if (!envConfigParser.success) {
// console.error(envConfigParser.error.issues)
// throw new Error('Invalid .env variable values')
// }
// export const envConfig = envConfigParser.data
// export default envConfig
// Core
// import { isAxiosError } from 'axios'
// App
// import i18n, { Language } from './i18n'
// Utils
// Extract error message
// export const extractErrorMessage = (error: unknown) => {
// const axiosErrorResponse = isAxiosError(error) ? error.response : null
// const errorMessage =
// i18n.language === Language.Vi
// ? (axiosErrorResponse?.data.message ?? 'Có lỗi xảy ra, vui lòng thử lại sau')
// : (axiosErrorResponse?.data.message_en ?? 'An error occurred, please try again later')
// return errorMessage
// }
// import slugify from 'slugify'
// export const generateSlug = (text: string) => {
// return slugify(text, {
// replacement: '-',
// lower: true,
// locale: 'vi',
// trim: true,
// strict: true
// })
// }
import Links from "@/links";
/**
* Helpers dùng chung cho các component hiển thị post (home, sections...).
* Toàn bộ là hàm thuần — an toàn khi dùng ở cả Server Components và client.
*/
/** Chuẩn hoá link: giữ http(s)/path, tự thêm "/" nếu thiếu, fallback nếu rỗng. */
export const normalizeLink = (value?: string | null, fallback = "/") => {
const trimmed = value?.trim();
if (!trimmed) return fallback;
if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith("/")) return trimmed;
return `/${trimmed}`;
};
/** Link chi tiết bài viết: slug/title + ?id=<postId> nếu có id. */
export const buildPostLink = (
path?: string | null,
id?: string | null,
fallback = "/",
) => {
const normalizedPath = normalizeLink(path, fallback);
const trimmedId = id?.trim() ?? "";
if (!trimmedId || normalizedPath === "#") return normalizedPath;
const params = new URLSearchParams({ id: trimmedId });
return `${normalizedPath}?${params.toString()}`;
};
/** Resolve URL asset (thumbnail...) qua Links.resolveImageUrl, fallback mặc định. */
export const resolveAssetUrl = (value?: string | null) => {
const trimmed = value?.trim();
if (!trimmed) return "/thumbnail.png";
return Links.resolveImageUrl(trimmed);
};
/** Bỏ caption/figure/img/tags + entities, trả text thuần dùng cho excerpt. */
export const stripHtml = (raw?: string | null) =>
(raw ?? "")
.replace(/\[caption[^\]]*\].*?\[\/caption\]/gi, "")
.replace(/<figure[^>]*>.*?<\/figure>/gi, "")
.replace(/<img[^>]*>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/–/g, "–")
.replace(/\s+/g, " ")
.trim();
// // Core
// import { decrypt, encrypt } from './crypto'
// import { PersistStorage, StorageValue } from 'zustand/middleware'
// import { getCookie, removeCookie, setCookie } from 'typescript-cookie'
// // App
// import envConfig from './env-config'
// export async function getItem(name: string) {
// const cipherText = getCookie(name)
// // const cipherText = localStorage.getItem(name)
// if (!cipherText) return null
// const originalText = await decrypt(cipherText, envConfig.SECRET_KEY, envConfig.BUFFER_KEY)
// if (!originalText) return null
// return JSON.parse(originalText)
// }
// export async function setItem<S>(name: string, value: StorageValue<S>) {
// const cipherText = await encrypt(JSON.stringify(value), envConfig.SECRET_KEY, envConfig.BUFFER_KEY)
// setCookie(name, cipherText)
// localStorage.setItem(name, cipherText)
// }
// export async function removeItem(name: string) {
// removeCookie(name)
// localStorage.removeItem(name)
// }
// // Utils
// // Create storage
// export const createStorage = <S>(): PersistStorage<S> => {
// return {
// getItem,
// setItem,
// removeItem
// }
// }
/**
* Type local — được khai báo trực tiếp ở đây để tránh vấn đề Next.js không resolve
* được module qua route group lồng `(main)/(home)`. Shape này được giữ đồng bộ
* với `HomePostItem` / `HomePostCategory` trong `use-home-posts.ts`.
* được module qua route group lồng `(main)/(home)`. Các component home tự định nghĩa
* type mapper riêng và map shape của mock data này sang type của chúng.
*/
export type HomePostCategory = {
id: string;
......@@ -749,15 +749,15 @@ type ShortPostParams = {
summary: string;
content: string;
categoryKey:
| "tinVcci"
| "tinKinhTe"
| "chuyenDe"
| "suKien"
| "chinhSachPhapLuat"
| "lienKetNhanh"
| "daoTao"
| "coHoiKinhDoanh"
| "ketNoiHoiVien";
| "tinVcci"
| "tinKinhTe"
| "chuyenDe"
| "suKien"
| "chinhSachPhapLuat"
| "lienKetNhanh"
| "daoTao"
| "coHoiKinhDoanh"
| "ketNoiHoiVien";
thumbnailUrl: string;
publishedAt: string;
startedAt?: string;
......
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