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

fix mockdata

parent be3a7d91
......@@ -7,9 +7,9 @@ import { Swiper as SwiperType } from "swiper/types";
import { useRef } from "react";
import "swiper/css";
import { useGetBanner } from "@/api/endpoints/banner";
import { getBanner } from "@/api/endpoints/banner";
import { useQuery } from "@tanstack/react-query";
import { fetchCmsFileById, resolveCmsFileUrl } from "@/lib/api/files";
import { resolveCmsFileUrl } from "@/lib/api/files";
import { Skeleton } from "@/components/ui/skeleton";
type ApiEnvelope<T> = {
......@@ -22,20 +22,65 @@ type ApiEnvelope<T> = {
const getEnvelopeData = <T,>(payload?: ApiEnvelope<T>) =>
payload?.responseData ?? payload?.data?.responseData;
function BannerSlideItem({ fileId, alt }: { fileId: string; alt: string }) {
type BannerRow = {
id: string;
file_id?: string | null;
banner_name?: string | null;
image_url?: string | null;
display_order?: number | null;
};
/**
* 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",
banner_name: "VCCI-HCM kết nối doanh nghiệp Việt Nam – Hoa Kỳ 2026",
image_url: "/thumbnail.png",
display_order: 1,
},
{
id: "mock-banner-2",
banner_name: "Hỗ trợ doanh nghiệp SME tiếp cận vốn tín dụng",
image_url: "/thumbnail.png",
display_order: 2,
},
{
id: "mock-banner-3",
banner_name: "AI trong quản trị hiệp hội doanh nghiệp",
image_url: "/thumbnail.png",
display_order: 3,
},
];
function BannerSlideItem({
src,
alt,
fileId,
}: {
src: string;
alt: string;
fileId?: string | null;
}) {
const { data: file, isPending } = useQuery({
queryKey: ["file", fileId],
queryFn: () => fetchCmsFileById(fileId),
enabled: !!fileId,
queryFn: () => Promise.resolve({ path: src }),
enabled: !!fileId && !src,
});
if (isPending) {
if (fileId && isPending && !src) {
return (
<Skeleton className="w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px]" />
);
}
const url = file ? resolveCmsFileUrl(file.path) : "/img-error.png";
const url = src
? src
: file
? resolveCmsFileUrl(file.path)
: "/img-error.png";
return (
<ImageNext
......@@ -52,14 +97,33 @@ function BannerSlideItem({ fileId, alt }: { fileId: string; alt: string }) {
const Banner = () => {
const swiperRef = useRef<SwiperType | null>(null);
const { data: bannerData } = useGetBanner({
filters: "status@=ACTIVE",
sortField: "display_order",
sortOrder: "asc",
const { data: bannerData, isPending, isError } = useQuery({
queryKey: ["home-banner"],
queryFn: () =>
getBanner({
filters: "status@=ACTIVE",
sortField: "display_order",
sortOrder: "asc",
}),
staleTime: 60 * 1000,
});
const pageData = getEnvelopeData<{ rows?: any[] }>(bannerData);
const rows = pageData?.rows ?? [];
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-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] bg-slate-100 flex items-center justify-center">
<Skeleton className="w-full h-full" />
</div>
);
}
if (!rows || rows.length === 0) {
return (
......@@ -78,25 +142,18 @@ const Banner = () => {
onSwiper={(s) => (swiperRef.current = s)}
className="w-full overflow-hidden"
>
{rows.map((row: any) => (
<SwiperSlide key={row.id}>
{row.file_id ? (
{rows.map((row) => {
const src = row.image_url ?? (row.file_id ? undefined : "/thumbnail.png");
return (
<SwiperSlide key={row.id}>
<BannerSlideItem
fileId={row.file_id}
alt={row.banner_name || "Banner"}
/>
) : (
<ImageNext
src="/img-error.png"
src={src ?? ""}
alt={row.banner_name || "Banner"}
width={2560}
height={720}
sizes="100vw"
className="w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
fileId={row.file_id ?? null}
/>
)}
</SwiperSlide>
))}
</SwiperSlide>
);
})}
</Swiper>
);
};
......
......@@ -28,6 +28,11 @@ type FeaturedMembersResponse = {
};
};
type FeaturedMemberState =
| { status: "loading" }
| { status: "empty" }
| { status: "ready"; rows: FeaturedMember[] };
const resolveMemberImage = (avatar: string | null | undefined, index: number) => {
if (avatar?.startsWith("http://") || avatar?.startsWith("https://")) {
return avatar;
......@@ -40,15 +45,11 @@ const resolveMemberImage = (avatar: string | null | undefined, index: number) =>
return memberImages[index % memberImages.length] ?? "/img-error.png";
};
const fallbackMembers: FeaturedMember[] = Array.from({ length: 9 }, (_, index) => ({
id: `fallback-member-${index}`,
name: `Hội viên tiêu biểu ${index + 1}`,
avatar: memberImages[index % memberImages.length],
}));
function Members() {
const { memberConnectionPosts, categoryLinks, categoryNames } = useHomePosts();
const [featuredMembers, setFeaturedMembers] = useState<FeaturedMember[]>([]);
const [featuredMembers, setFeaturedMembers] = useState<FeaturedMemberState>({
status: "loading",
});
const featuredConnection = memberConnectionPosts[0];
const sectionLink =
categoryLinks.get(categoryNames.ketNoiHoiVien.toLowerCase()) ?? "/hoi-vien/ket-noi-hoi-vien";
......@@ -56,7 +57,8 @@ function Members() {
featuredConnection?.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE;
const connectionImageAlt =
featuredConnection?.thumbnail?.alt || featuredConnection?.title || "VCCI HCM";
const displayMembers = featuredMembers.length > 0 ? featuredMembers : fallbackMembers;
const displayMembers =
featuredMembers.status === "ready" ? featuredMembers.rows.slice(0, 9) : [];
useEffect(() => {
let isMounted = true;
......@@ -75,10 +77,17 @@ function Members() {
const rows = data.responseData?.rows ?? [];
if (isMounted) {
setFeaturedMembers(rows.slice(0, 9));
setFeaturedMembers(
rows.length > 0
? { status: "ready", rows }
: { status: "empty" },
);
}
} catch (error) {
console.error(error);
if (isMounted) {
setFeaturedMembers({ status: "empty" });
}
}
};
......@@ -89,6 +98,61 @@ function Members() {
};
}, []);
const renderMemberContent = () => {
if (featuredMembers.status === "loading") {
return (
<div className="rounded-[16px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70">
Đang tải dữ liệu...
</div>
);
}
if (featuredMembers.status === "empty" || displayMembers.length === 0) {
return (
<div className="rounded-[16px] 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) => (
<SwiperSlide
key={member.id}
className="!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
<article className="rounded-[20px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]">
<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">
<ImageNext
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-[22px] bg-[#f7b500] p-4 shadow-[0_18px_34px_rgba(247,181,0,0.18)] md:p-5">
......@@ -112,40 +176,7 @@ function Members() {
<div className="mt-4 border-t border-[#e7aa00] pt-5" />
<Swiper
modules={[Autoplay]}
autoplay={{ delay: 4200, disableOnInteraction: false }}
observer
observeParents
updateOnWindowResize
slidesPerView="auto"
spaceBetween={16}
className="w-full"
>
{displayMembers.map((member, index) => (
<SwiperSlide
key={member.id}
className="!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
<article className="rounded-[20px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]">
<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">
<ImageNext
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>
{renderMemberContent()}
</aside>
<aside className="w-full xl:w-[31%] xl:min-w-[320px]">
......
......@@ -39,12 +39,64 @@ const resolvePartnerImage = (avatar: string | null | undefined, index: number) =
return partnerImages[index % partnerImages.length] ?? "/img-error.png";
};
const fallbackPartners: Partner[] = Array.from({ length: 6 }, (_, index) => ({
id: `fallback-partner-${index}`,
name: `Đối tác ${index + 1}`,
avatar: partnerImages[index % partnerImages.length],
website: null,
}));
const renderPartnerContent = (partners: Partner[]) => {
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-[14px] 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]">
<ImageNext
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-[14px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] xl:h-[151px]">
<ImageNext
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-[14px] 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 videosQuery = useQuery({
......@@ -71,7 +123,9 @@ function VideoAndPartners() {
const videos = videosQuery.data?.rows ?? [];
const partners = partnersQuery.data?.responseData?.rows?.slice(0, 12) ?? [];
const displayPartners = partners.length > 0 ? partners : fallbackPartners;
const hasPartnerError = partnersQuery.isError;
const displayPartners =
hasPartnerError || partners.length === 0 ? [] : partners;
return (
<section className="flex flex-col gap-6 pb-10 xl:flex-row xl:items-stretch">
......@@ -150,52 +204,7 @@ function VideoAndPartners() {
</div>
</div>
<Swiper
modules={[Autoplay]}
autoplay={{ delay: 4200, disableOnInteraction: false }}
observer
observeParents
updateOnWindowResize
slidesPerView="auto"
spaceBetween={16}
className="w-full"
>
{displayPartners.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-[14px] 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]">
<ImageNext
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-[14px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] xl:h-[151px]">
<ImageNext
src={resolvePartnerImage(partner.avatar, index)}
alt={partner.name}
width={140}
height={72}
className="max-h-full w-full object-contain"
/>
</div>
)}
</SwiperSlide>
))}
</Swiper>
{renderPartnerContent(displayPartners)}
</aside>
</section>
);
......
......@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
import { useCustomClient } from "@/api/mutator/custom-client";
import Links, { resolveUploadUrl } from "@/links";
import { MOCK_HOME_POSTS } from "@lib/mock-home-posts";
type RawHomeCategory = {
id?: string | null;
......@@ -279,6 +280,17 @@ function createEventCalendarQuery(currentMonth: Date) {
}
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 featuredQuery = new URLSearchParams({
page: "1",
pageSize: "3",
......@@ -550,56 +562,62 @@ export function useEventCalendarPosts(currentMonth: Date) {
queryKey: ["event-calendar-posts", currentMonth.getFullYear(), currentMonth.getMonth()],
queryFn: async () => {
const queryParams = createEventCalendarQuery(currentMonth);
const response = await useCustomClient<HomeEnvelope<HomePagedResult<RawHomePost>>>(
`/post?${queryParams.toString()}`,
);
return (response.responseData?.rows ?? []).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.external_link || (title ? `/${title}` : undefined),
item.id ? String(item.id) : "",
"#",
try {
const response = await useCustomClient<HomeEnvelope<HomePagedResult<RawHomePost>>>(
`/post?${queryParams.toString()}`,
);
return {
id: String(item.id ?? ""),
title,
externalLink,
summary: String(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 ?? ""),
categories,
thumbnail: thumbnailPath
? {
url: resolveAssetUrl(thumbnailPath),
alt: title,
}
: null,
} satisfies HomePostItem;
});
return (response.responseData?.rows ?? []).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.external_link || (title ? `/${title}` : undefined),
item.id ? String(item.id) : "",
"#",
);
return {
id: String(item.id ?? ""),
title,
externalLink,
summary: String(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 ?? ""),
categories,
thumbnail: thumbnailPath
? {
url: resolveAssetUrl(thumbnailPath),
alt: title,
}
: null,
} satisfies HomePostItem;
});
} catch (error) {
// eslint-disable-next-line no-console
console.warn("[useEventCalendarPosts] CMS unavailable, falling back to mock data", error);
return MOCK_HOME_POSTS;
}
},
staleTime: 5 * 60 * 1000,
});
......
import { NextResponse } from "next/server";
import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/app/api/mock-data";
const FEATURED_MEMBER_API_URL =
"https://vccihcm.vn/api/v1.0/organizations?filters=users.status_id+%3D%3D+36ca1cc5-7b6e-4f9f-b973-69c5207deb62&pageSize=12&sortField=created_at&sortOrder=ASC";
const BACKEND_HOST = process.env.NEXT_PUBLIC_BACKEND_HOST || "https://news.vccihcm.vn";
const FEATURED_MEMBER_API_URL = `${BACKEND_HOST.replace(/\/+$/, "")}/api/v1.0/vcci/featured-members`;
export async function GET() {
try {
const response = await fetch(FEATURED_MEMBER_API_URL, {
headers: {
Accept: "application/json",
},
headers: { Accept: "application/json" },
next: { revalidate: 300 },
});
if (!response.ok) {
return NextResponse.json(
{ message: "Không thể tải dữ liệu hội viên tiêu biểu" },
{ status: response.status },
// Khi upstream lỗi, vẫn trả mock để FE không hiển thị "Chưa có thông tin".
console.warn(
`[api/featured-members] upstream returned ${response.status}, serving mock data`,
);
return NextResponse.json(MOCK_FEATURED_MEMBERS_RESPONSE, { status: 200 });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Failed to fetch featured members", error);
return NextResponse.json(
{ message: "Không thể tải dữ liệu hội viên tiêu biểu" },
{ status: 500 },
);
console.error("[api/featured-members] upstream failed, serving mock data", error);
return NextResponse.json(MOCK_FEATURED_MEMBERS_RESPONSE, { status: 200 });
}
}
/**
* Mock data dự phòng cho 2 endpoint BFF:
* - GET /api/featured-members
* - GET /api/partners
*
* Khi upstream (vccihcm.vn hoặc BE proxy) bị lỗi / timeout, các route này sẽ
* trả về payload có shape giống upstream để FE component vẫn render dữ liệu
* thay vì hiển thị "Chưa có thông tin".
*
* Shape khớp với vccihcm.vn: { responseData: { rows: [...] } }
*/
export type MockMember = {
id: string;
name: string;
avatar: string | null;
};
export type MockPartner = {
id: string;
name: string;
avatar: string | null;
website: string | null;
};
export const MOCK_FEATURED_MEMBERS_RESPONSE = {
responseData: {
rows: [
{ id: "mock-member-1", name: "Công ty Cổ phần Vinamilk", avatar: "/home/hoi-vien-tieu-bieu/1.webp" },
{ id: "mock-member-2", name: "Tập đoàn Bất động sản Novaland", avatar: "/home/hoi-vien-tieu-bieu/2.webp" },
{ id: "mock-member-3", name: "Công ty TNHH Samsung Electronics Việt Nam", avatar: "/home/hoi-vien-tieu-bieu/3.webp" },
{ id: "mock-member-4", name: "Ngân hàng TMCP Ngoại thương Việt Nam (Vietcombank)", avatar: "/home/hoi-vien-tieu-bieu/4.webp" },
{ id: "mock-member-5", name: "Công ty Cổ phần FPT", avatar: "/home/hoi-vien-tieu-bieu/5.webp" },
{ id: "mock-member-6", name: "Tập đoàn Vingroup", avatar: "/home/hoi-vien-tieu-bieu/6.webp" },
{ id: "mock-member-7", name: "Công ty Cổ phần Thủy sản Minh Phú", avatar: "/home/hoi-vien-tieu-bieu/7.webp" },
{ id: "mock-member-8", name: "Tập đoàn Dệt may Việt Nam (Vinatex)", avatar: "/home/hoi-vien-tieu-bieu/8.webp" },
{ id: "mock-member-9", name: "Công ty Cổ phần Hàng không VietJet", avatar: "/home/hoi-vien-tieu-bieu/9.webp" },
] satisfies MockMember[],
},
};
export const MOCK_PARTNERS_RESPONSE = {
responseData: {
rows: [
{ id: "mock-partner-1", name: "Đối tác 1", avatar: "/home/doi-tac/1.webp", website: "https://example.com" },
{ id: "mock-partner-2", name: "Đối tác 2", avatar: "/home/doi-tac/2.webp", website: "https://example.com" },
{ id: "mock-partner-3", name: "Đối tác 3", avatar: "/home/doi-tac/3.webp", website: null },
{ id: "mock-partner-4", name: "Đối tác 4", avatar: "/home/doi-tac/4.webp", website: null },
{ id: "mock-partner-5", name: "Đối tác 5", avatar: "/home/doi-tac/5.webp", website: null },
{ id: "mock-partner-6", name: "Đối tác 6", avatar: "/home/doi-tac/6.webp", website: null },
] satisfies MockPartner[],
},
};
import { NextResponse } from "next/server";
import { MOCK_PARTNERS_RESPONSE } from "@/app/api/mock-data";
const PARTNER_API_URL =
"https://vccihcm.vn/api/v1.0/organizations?filters=type%3D%3DSPONSOR&pageSize=12&sortField=sort_order&sortOrder=ASC";
const BACKEND_HOST = process.env.NEXT_PUBLIC_BACKEND_HOST || "https://news.vccihcm.vn";
const PARTNER_API_URL = `${BACKEND_HOST.replace(/\/+$/, "")}/api/v1.0/vcci/partners`;
export async function GET() {
try {
const response = await fetch(PARTNER_API_URL, {
headers: {
Accept: "application/json",
},
headers: { Accept: "application/json" },
next: { revalidate: 300 },
});
if (!response.ok) {
return NextResponse.json(
{ message: "Không thể tải dữ liệu đối tác" },
{ status: response.status },
// Khi upstream lỗi, vẫn trả mock để FE không hiển thị "Chưa có thông tin".
console.warn(
`[api/partners] upstream returned ${response.status}, serving mock data`,
);
return NextResponse.json(MOCK_PARTNERS_RESPONSE, { status: 200 });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Failed to fetch partners", error);
return NextResponse.json(
{ message: "Không thể tải dữ liệu đối tác" },
{ status: 500 },
);
console.error("[api/partners] upstream failed, serving mock data", error);
return NextResponse.json(MOCK_PARTNERS_RESPONSE, { status: 200 });
}
}
......@@ -80,25 +80,33 @@ export const normalizeVideoUrl = (url: string) => {
};
export async function fetchClientVideos(params?: { page?: number; pageSize?: number }) {
const response = await getVideo({
page: params?.page ?? 1,
pageSize: params?.pageSize ?? 10,
sortField: "created_at",
sortOrder: "desc",
});
const pageData = readVideoPageData(response);
const pageSize = pageData.pageSize ?? params?.pageSize ?? 10;
const count = pageData.count ?? 0;
return {
rows: readVideoRows(response).map((item) => ({
...item,
thumbnail: getVideoThumbnail(item.url),
watchUrl: normalizeVideoUrl(item.url),
})),
count,
page: pageData.page ?? params?.page ?? 1,
pageSize,
totalPages: Math.max(1, Math.ceil(count / pageSize)),
} satisfies ClientVideoListResult;
try {
const response = await getVideo({
page: params?.page ?? 1,
pageSize: params?.pageSize ?? 10,
sortField: "created_at",
sortOrder: "desc",
});
const pageData = readVideoPageData(response);
const pageSize = pageData.pageSize ?? params?.pageSize ?? 10;
const count = pageData.count ?? 0;
return {
rows: readVideoRows(response).map((item) => ({
...item,
thumbnail: getVideoThumbnail(item.url),
watchUrl: normalizeVideoUrl(item.url),
})),
count,
page: pageData.page ?? params?.page ?? 1,
pageSize,
totalPages: Math.max(1, Math.ceil(count / pageSize)),
} satisfies ClientVideoListResult;
} catch (error) {
// Khi CMS / BE gặp sự cố — rethrow để UI component tự xử lý
// (hiển thị trạng thái "Chưa có video nào.").
// eslint-disable-next-line no-console
console.warn("[fetchClientVideos] CMS unavailable", error);
throw error;
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
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