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

fix

parent adfed5ce
...@@ -12,14 +12,16 @@ interface RetriableAxiosRequestConfig extends InternalAxiosRequestConfig { ...@@ -12,14 +12,16 @@ interface RetriableAxiosRequestConfig extends InternalAxiosRequestConfig {
const createAxiosInstance = () => { const createAxiosInstance = () => {
const instance = Axios.create({ const instance = Axios.create({
baseURL: links.apiEndpoint, baseURL: links.apiEndpoint,
withCredentials: true, withCredentials: false,
}); });
instance.interceptors.request.use(async (config) => { instance.interceptors.request.use(async (config) => {
if (shouldSkipAuthHandling(config.url)) { if (shouldSkipAuthHandling(config.url) || !shouldHandleAdminAuth()) {
config.withCredentials = false;
return config; return config;
} }
config.withCredentials = true;
const token = await ensureValidAdminAccessToken().catch(() => null); const token = await ensureValidAdminAccessToken().catch(() => null);
if (token) { if (token) {
...@@ -40,7 +42,8 @@ const createAxiosInstance = () => { ...@@ -40,7 +42,8 @@ const createAxiosInstance = () => {
error.response?.status !== 401 || error.response?.status !== 401 ||
!originalRequest || !originalRequest ||
originalRequest._retry || originalRequest._retry ||
shouldSkipAuthHandling(originalRequest.url) shouldSkipAuthHandling(originalRequest.url) ||
!shouldHandleAdminAuth()
) { ) {
return Promise.reject(error); return Promise.reject(error);
} }
...@@ -89,6 +92,11 @@ const shouldSkipAuthHandling = (url?: string | null) => { ...@@ -89,6 +92,11 @@ const shouldSkipAuthHandling = (url?: string | null) => {
return /\/auth\/(login|refresh|logout)(\?|$)/.test(url); return /\/auth\/(login|refresh|logout)(\?|$)/.test(url);
}; };
const shouldHandleAdminAuth = () => {
if (typeof window === "undefined") return false;
return window.location.pathname.startsWith("/admin");
};
const convertHeaders = (headers?: HeadersInit): Record<string, string> | undefined => { const convertHeaders = (headers?: HeadersInit): Record<string, string> | undefined => {
if (!headers) return undefined; if (!headers) return undefined;
......
...@@ -135,6 +135,9 @@ const HOME_CATEGORY_IDS = { ...@@ -135,6 +135,9 @@ const HOME_CATEGORY_IDS = {
tinKinhTe: "755106b6-1aca-47dc-9a9c-d434736c33a1", tinKinhTe: "755106b6-1aca-47dc-9a9c-d434736c33a1",
chuyenDe: "8e7090e5-bfc3-4128-81a5-37ec78c33bad", chuyenDe: "8e7090e5-bfc3-4128-81a5-37ec78c33bad",
suKien: "b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a", 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", chinhSachPhapLuat: "cc448be9-b9ea-46a8-aa7b-0584803330e8",
lienKetNhanh: "d7f05384-b1b4-428e-b9b3-37e0e1b0cecd", lienKetNhanh: "d7f05384-b1b4-428e-b9b3-37e0e1b0cecd",
} as const; } as const;
...@@ -232,33 +235,6 @@ async function fetchHomePostRows(path: string) { ...@@ -232,33 +235,6 @@ async function fetchHomePostRows(path: string) {
return response.responseData?.rows ?? []; return response.responseData?.rows ?? [];
} }
async function fetchHomeCategoryRows() {
const response = await useCustomClient<HomeEnvelope<HomePagedResult<RawHomeCategory>>>(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC",
);
return response.responseData?.rows ?? [];
}
function findCategoryIdByAliases(
categories: RawHomeCategory[],
aliases: readonly string[],
) {
const aliasKeys = new Set(aliases.map(normalizeSearchText));
const aliasSlugs = new Set(aliases.map(normalizeSlug));
return categories.find((category) => {
const categoryNameKey = normalizeSearchText(category.name);
const categorySlugKey = normalizeSlug(category.slug || category.name);
const categoryUrlKey = normalizeSlug(category.url);
return (
aliasKeys.has(categoryNameKey) ||
aliasSlugs.has(categorySlugKey) ||
Array.from(aliasSlugs).some((slug) => categoryUrlKey.endsWith(slug))
);
})?.id ?? null;
}
function createCategoryPostsQuery(categoryId: string, pageSize: string) { function createCategoryPostsQuery(categoryId: string, pageSize: string) {
return new URLSearchParams({ return new URLSearchParams({
page: "1", page: "1",
...@@ -276,19 +252,6 @@ function createCategoryPostsQuery(categoryId: string, pageSize: string) { ...@@ -276,19 +252,6 @@ function createCategoryPostsQuery(categoryId: string, pageSize: string) {
} }
async function fetchHomePosts() { async function fetchHomePosts() {
const categoryRows = await fetchHomeCategoryRows().catch(() => []);
const trainingCategoryId = findCategoryIdByAliases(
categoryRows,
HOME_CATEGORY_ALIASES.daoTao,
);
const businessCategoryId = findCategoryIdByAliases(
categoryRows,
HOME_CATEGORY_ALIASES.coHoiKinhDoanh,
);
const memberConnectionCategoryId = findCategoryIdByAliases(
categoryRows,
HOME_CATEGORY_ALIASES.ketNoiHoiVien,
);
const featuredQuery = new URLSearchParams({ const featuredQuery = new URLSearchParams({
page: "1", page: "1",
pageSize: "10", pageSize: "10",
...@@ -308,15 +271,9 @@ async function fetchHomePosts() { ...@@ -308,15 +271,9 @@ async function fetchHomePosts() {
const eventQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.suKien, "5"); const eventQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.suKien, "5");
const policyQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.chinhSachPhapLuat, "6"); const policyQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.chinhSachPhapLuat, "6");
const quickLinksQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.lienKetNhanh, "6"); const quickLinksQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.lienKetNhanh, "6");
const trainingQuery = trainingCategoryId const trainingQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.daoTao, "10");
? createCategoryPostsQuery(String(trainingCategoryId), "10") const businessQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.coHoiKinhDoanh, "10");
: null; const memberConnectionQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.ketNoiHoiVien, "10");
const businessQuery = businessCategoryId
? createCategoryPostsQuery(String(businessCategoryId), "10")
: null;
const memberConnectionQuery = memberConnectionCategoryId
? createCategoryPostsQuery(String(memberConnectionCategoryId), "10")
: null;
const [ const [
featuredRows, featuredRows,
...@@ -337,9 +294,9 @@ async function fetchHomePosts() { ...@@ -337,9 +294,9 @@ async function fetchHomePosts() {
fetchHomePostRows(`/post?${policyQuery.toString()}`), fetchHomePostRows(`/post?${policyQuery.toString()}`),
fetchHomePostRows(`/post?${eventQuery.toString()}`), fetchHomePostRows(`/post?${eventQuery.toString()}`),
fetchHomePostRows(`/post?${quickLinksQuery.toString()}`), fetchHomePostRows(`/post?${quickLinksQuery.toString()}`),
trainingQuery ? fetchHomePostRows(`/post?${trainingQuery.toString()}`) : [], fetchHomePostRows(`/post?${trainingQuery.toString()}`),
businessQuery ? fetchHomePostRows(`/post?${businessQuery.toString()}`) : [], fetchHomePostRows(`/post?${businessQuery.toString()}`),
memberConnectionQuery ? fetchHomePostRows(`/post?${memberConnectionQuery.toString()}`) : [], fetchHomePostRows(`/post?${memberConnectionQuery.toString()}`),
]); ]);
const rows = [ const rows = [
......
'use client'; 'use client';
import dayjs from "dayjs"; import dayjs from "dayjs";
import parse from "html-react-parser";
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar"; import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import { getDynamicPostBodyHtml } from "./data"; import { buildDynamicCategoryMenu } from "./data";
import StructuredPostContent from "./StructuredPostContent";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types"; import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
type ArticleDetailPageProps = { type ArticleDetailPageProps = {
...@@ -16,15 +17,18 @@ type ArticleDetailPageProps = { ...@@ -16,15 +17,18 @@ type ArticleDetailPageProps = {
export default function ArticleDetailPage({ export default function ArticleDetailPage({
post, post,
category, category,
allCategories,
}: ArticleDetailPageProps) { }: ArticleDetailPageProps) {
const publishedDate = dayjs( const publishedDate = dayjs(
post.release_at ?? post.published_at ?? post.created_at, post.release_at ?? post.published_at ?? post.created_at,
).format("DD/MM/YYYY"); ).format("DD/MM/YYYY");
const primaryCategory = post.categories[0]?.name || category?.name || "Tin tức"; const primaryCategory = post.categories[0]?.name || category?.name || "Tin tức";
const categoryMenu = category ? buildDynamicCategoryMenu(category, allCategories) : [];
return ( return (
<div className="min-h-screen bg-[#fbfbfa]"> <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"> {/* {categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null} */}
<div className="container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10">
<div className="grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_340px] xl:gap-12"> <div className="grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_340px] xl:gap-12">
<main className="min-w-0"> <main className="min-w-0">
<div className="mb-5 flex flex-wrap items-center gap-3 text-xs"> <div className="mb-5 flex flex-wrap items-center gap-3 text-xs">
...@@ -45,9 +49,9 @@ export default function ArticleDetailPage({ ...@@ -45,9 +49,9 @@ export default function ArticleDetailPage({
</p> </p>
) : null} ) : null}
<div className="mt-7 rounded-[24px] bg-white px-4 py-5 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 sm:py-6 lg:px-10"> <div className="mt-7 rounded-3xl bg-white px-4 py-5 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 sm:py-6 lg:px-10">
<div className="article-detail-content prose tiptap max-w-none overflow-hidden"> <div className="article-detail-content prose tiptap max-w-none overflow-hidden">
{parse(getDynamicPostBodyHtml(post))} <StructuredPostContent post={post} />
</div> </div>
</div> </div>
...@@ -116,7 +120,7 @@ export default function ArticleDetailPage({ ...@@ -116,7 +120,7 @@ export default function ArticleDetailPage({
</div> </div>
</main> </main>
<aside className="space-y-5"> <aside className="space-y-5 xl:pt-0">
<EventsCalendar compact className="xl:w-full xl:min-w-0" /> <EventsCalendar compact className="xl:w-full xl:min-w-0" />
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"> <div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]">
<div className="relative min-h-[390px] bg-[#1f334f]"> <div className="relative min-h-[390px] bg-[#1f334f]">
...@@ -127,13 +131,13 @@ export default function ArticleDetailPage({ ...@@ -127,13 +131,13 @@ export default function ArticleDetailPage({
height={760} height={760}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" /> <div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white"> <div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70"> <div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá Đối tác quảng bá
</div> </div>
<div className="mt-3 text-2xl font-bold leading-tight"> <div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên Business Combo cho hội viên doanh nghiệp
</div> </div>
</div> </div>
</div> </div>
...@@ -144,3 +148,4 @@ export default function ArticleDetailPage({ ...@@ -144,3 +148,4 @@ export default function ArticleDetailPage({
</div> </div>
); );
} }
...@@ -9,7 +9,9 @@ import { Pagination } from "@/components/base/pagination"; ...@@ -9,7 +9,9 @@ import { Pagination } from "@/components/base/pagination";
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
import { import {
buildDynamicCategoryMenu,
buildPostFilters, buildPostFilters,
fetchDynamicPostList, fetchDynamicPostList,
resolveDynamicPostImage, resolveDynamicPostImage,
...@@ -102,15 +104,20 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -102,15 +104,20 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
return new Map(entries); return new Map(entries);
}, [allCategories]); }, [allCategories]);
const categoryMenu = useMemo(
() => buildDynamicCategoryMenu(category, allCategories),
[category, allCategories],
);
return ( return (
<div className="min-h-screen bg-[#fbfbfa]"> <div className="min-h-screen bg-white">
{categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null}
{postsQuery.isLoading ? ( {postsQuery.isLoading ? (
<div className="flex justify-center items-center w-full h-64"> <div className="flex justify-center items-center w-full h-64">
<Spinner /> <Spinner />
</div> </div>
) : ( ) : (
<div className="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10"> <div className="container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl"> <h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
{category.name} {category.name}
...@@ -197,9 +204,9 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -197,9 +204,9 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
</div> </div>
</main> </main>
<aside className="order-1 space-y-5 xl:order-2 xl:w-[320px] xl:pt-0"> <aside className="contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5 xl:pt-0">
<form <form
className="rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]" 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) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
setPage(1); setPage(1);
...@@ -235,7 +242,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -235,7 +242,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
</div> </div>
</form> </form>
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"> <div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-none">
<div className="relative min-h-[390px] bg-[#1f334f]"> <div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext <ImageNext
src="/banner.webp" src="/banner.webp"
...@@ -244,13 +251,13 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -244,13 +251,13 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
height={760} height={760}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" /> <div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white"> <div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70"> <div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá Đối tác quảng bá
</div> </div>
<div className="mt-3 text-2xl font-bold leading-tight"> <div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên Business Combo cho hội viên doanh nghiệp
</div> </div>
</div> </div>
</div> </div>
......
...@@ -9,7 +9,9 @@ import { Pagination } from "@/components/base/pagination"; ...@@ -9,7 +9,9 @@ import { Pagination } from "@/components/base/pagination";
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
import { import {
buildDynamicCategoryMenu,
buildPostFilters, buildPostFilters,
fetchDynamicPostList, fetchDynamicPostList,
resolveDynamicPostImage, resolveDynamicPostImage,
...@@ -81,15 +83,17 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp ...@@ -81,15 +83,17 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
const totalPages = postsQuery.data?.totalPages ?? 1; const totalPages = postsQuery.data?.totalPages ?? 1;
const currentPage = Math.min(page, totalPages); const currentPage = Math.min(page, totalPages);
const paginatedPosts = postsQuery.data?.rows ?? []; const paginatedPosts = postsQuery.data?.rows ?? [];
const categoryMenu = buildDynamicCategoryMenu(category, allCategories);
return ( return (
<div className="min-h-screen bg-[#fbfbfa]"> <div className="min-h-screen bg-white">
{categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null}
{postsQuery.isLoading ? ( {postsQuery.isLoading ? (
<div className="flex h-64 w-full items-center justify-center"> <div className="flex h-64 w-full items-center justify-center">
<Spinner /> <Spinner />
</div> </div>
) : ( ) : (
<div className="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10"> <div className="container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl"> <h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
{category.name} {category.name}
...@@ -109,7 +113,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp ...@@ -109,7 +113,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
className="group block" className="group block"
> >
<div className="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]"> <div className="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]">
<div className="relative aspect-[3/4] overflow-hidden bg-white"> <div className="relative aspect-3/4 overflow-hidden bg-white">
<ImageNext <ImageNext
src={resolveDynamicPostImage(item.thumbnail)} src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title} alt={item.title}
...@@ -131,7 +135,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp ...@@ -131,7 +135,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
</div> </div>
) : ( ) : (
<div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600"> <div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600">
{"Ch\u01b0a c\u00f3 b\u00e0i vi\u1ebft ph\u00f9 h\u1ee3p trong danh m\u1ee5c n\u00e0y."} Chưa có tài liệu trong danh mục.
</div> </div>
)} )}
...@@ -146,9 +150,9 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp ...@@ -146,9 +150,9 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
</div> </div>
</main> </main>
<aside className="order-1 space-y-5 xl:order-2 xl:w-[320px] xl:pt-0"> <aside className="contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5 xl:pt-0">
<form <form
className="rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]" 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) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
setPage(1); setPage(1);
...@@ -184,7 +188,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp ...@@ -184,7 +188,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
</div> </div>
</form> </form>
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"> <div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-0">
<div className="relative min-h-[390px] bg-[#1f334f]"> <div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext <ImageNext
src="/banner.webp" src="/banner.webp"
...@@ -193,13 +197,13 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp ...@@ -193,13 +197,13 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
height={760} height={760}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" /> <div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white"> <div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70"> <div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá Đối tác quảng bá
</div> </div>
<div className="mt-3 text-2xl font-bold leading-tight"> <div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên Business Combo cho hội viên doanh nghiệp
</div> </div>
</div> </div>
</div> </div>
......
'use client'; 'use client';
import dayjs from "dayjs"; import dayjs from "dayjs";
import parse from "html-react-parser"; import ListCategory from "@/components/base/list-category";
import { getDynamicPostBodyHtml } from "./data"; import { buildDynamicCategoryMenu } from "./data";
import StructuredPostContent from "./StructuredPostContent";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types"; import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
type InformationPageProps = { type InformationPageProps = {
...@@ -14,21 +15,24 @@ type InformationPageProps = { ...@@ -14,21 +15,24 @@ type InformationPageProps = {
export default function InformationPage({ export default function InformationPage({
post, post,
category, category,
allCategories,
}: InformationPageProps) { }: InformationPageProps) {
const publishedDate = dayjs( const publishedDate = dayjs(
post.release_at ?? post.published_at ?? post.created_at, post.release_at ?? post.published_at ?? post.created_at,
).format("DD/MM/YYYY"); ).format("DD/MM/YYYY");
const categoryMenu = buildDynamicCategoryMenu(category, allCategories);
return ( return (
<div className="min-h-screen bg-[#fbfbfa]"> <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"> {categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null}
<div className="container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10">
<main className="w-full"> <main className="w-full">
<div className="mb-5 flex flex-wrap items-center gap-3 text-xs"> {/* <div className="mb-5 flex flex-wrap items-center gap-3 text-xs">
<span className="rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]"> <span className="rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]">
{category.name} {category.name}
</span> </span>
<span className="text-[#9aa3ad]">{publishedDate}</span> <span className="text-[#9aa3ad]">{publishedDate}</span>
</div> </div> */}
<h1 className="max-w-6xl text-3xl font-bold leading-tight text-[#111827] md:text-[38px] md:leading-[1.15]"> <h1 className="max-w-6xl text-3xl font-bold leading-tight text-[#111827] md:text-[38px] md:leading-[1.15]">
{post.title} {post.title}
...@@ -41,9 +45,9 @@ export default function InformationPage({ ...@@ -41,9 +45,9 @@ export default function InformationPage({
</p> </p>
) : null} ) : null}
<div className="mt-7 rounded-[24px] bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10"> <div className="mt-7 rounded-3xl bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10">
<div className="page-detail-content prose tiptap max-w-none overflow-hidden"> <div className="page-detail-content prose tiptap max-w-none overflow-hidden">
{parse(getDynamicPostBodyHtml(post))} <StructuredPostContent post={post} />
</div> </div>
</div> </div>
......
"use client";
import parse from "html-react-parser";
import ImageNext from "@/components/shared/image-next";
import { getDynamicPostBodyHtml } from "./data";
import type { DynamicPostContentSection, DynamicPostItem } from "./types";
type StructuredPostContentProps = {
post: DynamicPostItem;
};
function getGridClassName(columns: number) {
if (columns >= 4) return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4";
if (columns === 3) return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
if (columns === 2) return "grid-cols-1 sm:grid-cols-2";
return "grid-cols-1";
}
function StructuredImageSection({ section }: { section: DynamicPostContentSection }) {
const images = section.images.filter((item) => item.image?.url);
if (!images.length) return null;
return (
<div className={`not-prose my-6 grid gap-4 ${getGridClassName(section.image_columns)}`}>
{images.map((item) => {
const image = item.image;
if (!image?.url) return null;
return (
<figure
key={`${section.id}-${image.id || image.url}-${item.position}`}
className="overflow-hidden rounded-[18px] bg-white"
>
<ImageNext
src={image.url}
alt={image.alt || image.name || "Hình ảnh bài viết"}
width={1200}
height={800}
className="h-auto w-full object-contain"
/>
</figure>
);
})}
</div>
);
}
export default function StructuredPostContent({ post }: StructuredPostContentProps) {
const sections = (post.content_structure?.post_content ?? [])
.slice()
.sort((left, right) => left.position - right.position);
if (!sections.length) {
return <>{parse(getDynamicPostBodyHtml(post))}</>;
}
const hasRenderableSection = sections.some(
(section) => section.content.trim() || section.images.length,
);
if (!hasRenderableSection) {
return <>{parse(getDynamicPostBodyHtml(post))}</>;
}
return (
<>
{sections.map((section) => {
if (section.type === "image") {
return <StructuredImageSection key={section.id} section={section} />;
}
const content = section.content.trim();
if (!content) return null;
return <div key={section.id}>{parse(content)}</div>;
})}
</>
);
}
...@@ -30,6 +30,18 @@ type RawPostThumbnail = { ...@@ -30,6 +30,18 @@ type RawPostThumbnail = {
url?: string | null; url?: string | null;
}; };
type RawPostSectionImage = {
position?: number | null;
image?: {
id?: string | null;
name?: string | null;
alt?: string | null;
url?: string | null;
path?: string | null;
original?: string | null;
} | null;
};
type RawPostItem = { type RawPostItem = {
id?: string | null; id?: string | null;
title?: string | null; title?: string | null;
...@@ -57,6 +69,9 @@ type RawPostItem = { ...@@ -57,6 +69,9 @@ type RawPostItem = {
type?: string | null; type?: string | null;
content?: string | null; content?: string | null;
position?: number | null; position?: number | null;
image_rows?: number | null;
image_columns?: number | null;
images?: RawPostSectionImage[] | null;
}> | null; }> | null;
} | null; } | null;
}; };
...@@ -111,6 +126,33 @@ const mapPostContentSections = (item: RawPostItem): DynamicPostContentSection[] ...@@ -111,6 +126,33 @@ const mapPostContentSections = (item: RawPostItem): DynamicPostContentSection[]
typeof section?.position === "number" typeof section?.position === "number"
? section.position ? section.position
: index + 1, : index + 1,
image_rows:
typeof section?.image_rows === "number" && section.image_rows > 0
? section.image_rows
: 1,
image_columns:
typeof section?.image_columns === "number" && section.image_columns > 0
? section.image_columns
: 1,
images: (section?.images ?? [])
.map((item, imageIndex) => ({
position:
typeof item?.position === "number"
? item.position
: imageIndex + 1,
image: item?.image
? {
id: String(item.image.id ?? ""),
name: String(item.image.name ?? item.image.original ?? ""),
alt: String(item.image.alt ?? item.image.name ?? ""),
url: resolveUploadUrl(item.image.url ?? item.image.path ?? item.image.original ?? ""),
path: item.image.path ?? null,
original: item.image.original ?? null,
}
: null,
}))
.filter((item) => Boolean(item.image?.url))
.sort((left, right) => left.position - right.position),
})); }));
}; };
......
...@@ -34,6 +34,19 @@ export type DynamicPostContentSection = { ...@@ -34,6 +34,19 @@ export type DynamicPostContentSection = {
type: string; type: string;
content: string; content: string;
position: number; position: number;
image_rows: number;
image_columns: number;
images: Array<{
position: number;
image: {
id: string;
name: string;
alt: string;
url: string;
path?: string | null;
original?: string | null;
} | null;
}>;
}; };
export type DynamicPostItem = { export type DynamicPostItem = {
......
...@@ -126,6 +126,14 @@ function Header() { ...@@ -126,6 +126,14 @@ function Header() {
}; };
}, []); }, []);
useEffect(() => {
document.body.style.overflow = toggleMenu ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [toggleMenu]);
return ( return (
<header className="sticky top-0 z-50 shadow-[0_1px_0_rgba(15,23,42,0.05)]"> <header className="sticky top-0 z-50 shadow-[0_1px_0_rgba(15,23,42,0.05)]">
<div <div
...@@ -252,53 +260,73 @@ function Header() { ...@@ -252,53 +260,73 @@ function Header() {
</div> </div>
<div <div
className={`fixed left-0 right-0 top-[80px] z-40 border-t border-slate-200 bg-white transition-all duration-300 lg:hidden ${ className={`fixed inset-0 z-[60] bg-white transition-all duration-300 lg:hidden ${
toggleMenu toggleMenu
? "pointer-events-auto h-[calc(100dvh-80px)] translate-y-0 opacity-100" ? "pointer-events-auto translate-y-0 opacity-100"
: "pointer-events-none h-[calc(100dvh-80px)] -translate-y-2 opacity-0" : "pointer-events-none -translate-y-2 opacity-0"
}`} }`}
> >
<div className="flex h-full flex-col overflow-y-auto overscroll-contain px-4 py-3"> <div className="flex h-full flex-col overflow-hidden">
<input <div className="sticky top-0 z-10 flex h-[78px] shrink-0 items-center justify-between border-b border-slate-100 bg-white px-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
className="h-11 w-full shrink-0 rounded-md border border-slate-200 px-4 text-sm outline-none placeholder:text-slate-400 focus:border-[#2f57ff]" <Link href="/" className="flex w-[136px] shrink-0 items-center" onClick={() => setToggleMenu(false)}>
type="text" <Image
placeholder={"T\u00ecm ki\u1ebfm"} className="h-auto w-[108px] object-contain"
onKeyDown={(e) => { src={logo}
if (e.key === "Enter") { alt="VCCI-HCM"
const value = (e.currentTarget as HTMLInputElement).value || ""; priority
const encoded = encodeURIComponent(value); />
router.push(`/search?q=${encoded}&page=1`); </Link>
setToggleMenu(false); <button
} onClick={() => setToggleMenu(false)}
}} className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 bg-white text-[#163b73] transition hover:bg-slate-50"
/> aria-label={\u00f3ng menu"}
>
<div className="pb-6"> <X size={18} />
{menuItems.map((category) => ( </button>
<div key={category.id} className="border-t border-slate-100 first:border-t-0"> </div>
<Link
href={category.url || "#"} <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
className="block px-5 py-3 text-[15px] font-medium text-slate-700 transition hover:bg-slate-50 hover:text-[#2f57ff]" <input
onClick={() => setToggleMenu(false)} className="h-11 w-full shrink-0 rounded-md border border-slate-200 px-4 text-sm outline-none placeholder:text-slate-400 focus:border-[#2f57ff]"
> type="text"
{category.name} placeholder={"T\u00ecm ki\u1ebfm"}
</Link> onKeyDown={(e) => {
{category.children.length > 0 ? ( if (e.key === "Enter") {
<div className="pb-2 pl-8 pr-5"> const value = (e.currentTarget as HTMLInputElement).value || "";
{category.children.map((child) => ( const encoded = encodeURIComponent(value);
<Link router.push(`/search?q=${encoded}&page=1`);
key={child.id} setToggleMenu(false);
href={child.url || "#"} }
className="block py-2 text-sm text-slate-500 transition hover:text-[#2f57ff]" }}
onClick={() => setToggleMenu(false)} />
>
{child.name} <div className="pb-6">
</Link> {menuItems.map((category) => (
))} <div key={category.id} className="border-t border-slate-100 first:border-t-0">
</div> <Link
) : null} href={category.url || "#"}
</div> className="block px-5 py-3 text-[15px] font-medium text-slate-700 transition hover:bg-slate-50 hover:text-[#2f57ff]"
))} onClick={() => setToggleMenu(false)}
>
{category.name}
</Link>
{category.children.length > 0 ? (
<div className="pb-2 pl-8 pr-5">
{category.children.map((child) => (
<Link
key={child.id}
href={child.url || "#"}
className="block py-2 text-sm text-slate-500 transition hover:text-[#2f57ff]"
onClick={() => setToggleMenu(false)}
>
{child.name}
</Link>
))}
</div>
) : null}
</div>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -140,7 +140,7 @@ function SearchContent() { ...@@ -140,7 +140,7 @@ function SearchContent() {
const currentPage = Number(postsQuery.data?.page ?? page); const currentPage = Number(postsQuery.data?.page ?? page);
return ( return (
<div className="min-h-screen bg-[#fbfbfa]"> <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="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl"> <h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
...@@ -197,9 +197,9 @@ function SearchContent() { ...@@ -197,9 +197,9 @@ function SearchContent() {
)} )}
</main> </main>
<aside className="order-1 space-y-5 xl:order-2 xl:w-[320px] xl:pt-0"> <aside className="contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5 xl:pt-0">
<form <form
className="rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]" 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) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
setPage(1); setPage(1);
...@@ -235,7 +235,7 @@ function SearchContent() { ...@@ -235,7 +235,7 @@ function SearchContent() {
</div> </div>
</form> </form>
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"> <div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-none">
<div className="relative min-h-[390px] bg-[#1f334f]"> <div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext <ImageNext
src="/banner.webp" src="/banner.webp"
...@@ -266,7 +266,7 @@ export default function Page() { ...@@ -266,7 +266,7 @@ export default function Page() {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div className="flex min-h-screen items-center justify-center bg-[#fbfbfa]"> <div className="flex min-h-screen items-center justify-center bg-white">
<Spinner className="size-8" /> <Spinner className="size-8" />
</div> </div>
} }
......
...@@ -42,7 +42,7 @@ function VideoPageContent() { ...@@ -42,7 +42,7 @@ function VideoPageContent() {
}; };
return ( return (
<div className="min-h-screen bg-[#fbfbfa]"> <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="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl"> <h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
...@@ -126,7 +126,7 @@ export default function Page() { ...@@ -126,7 +126,7 @@ export default function Page() {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div className="flex min-h-screen items-center justify-center bg-[#fbfbfa]"> <div className="flex min-h-screen items-center justify-center bg-white">
<Spinner className="size-8" /> <Spinner className="size-8" />
</div> </div>
} }
......
...@@ -19,10 +19,10 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] } ...@@ -19,10 +19,10 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }
const isActive = (href: string) => pathname === href; const isActive = (href: string) => pathname === href;
return ( return (
<div className="border-t border-gray-200 bg-white py-2"> <div className="border-t border-gray-200 bg-white">
<div className="w-full px-4 sm:px-6 lg:px-8"> <div className="container mx-auto px-4 sm:px-6 lg:px-10">
<div className="py-3"> <div className="pt-6">
<div className="flex max-w-full items-center gap-3 overflow-x-auto pb-1"> <div className="client-category-scrollbar flex max-w-full items-center gap-3 overflow-x-auto overflow-y-hidden pb-1 pl-0.5 pr-2">
{categories.map((category) => { {categories.map((category) => {
const href = resolveHref(category); const href = resolveHref(category);
const menu = { id: category.id, name: category.name, link: href }; const menu = { id: category.id, name: category.name, link: href };
...@@ -37,6 +37,29 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] } ...@@ -37,6 +37,29 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }
</div> </div>
</div> </div>
</div> </div>
<style jsx global>{`
.client-category-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(22, 85, 157, 0.35) transparent;
}
.client-category-scrollbar::-webkit-scrollbar {
height: 6px;
}
.client-category-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.client-category-scrollbar::-webkit-scrollbar-thumb {
background: rgba(22, 85, 157, 0.28);
border-radius: 999px;
}
.client-category-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(22, 85, 157, 0.48);
}
`}</style>
</div> </div>
); );
}; };
......
...@@ -40,7 +40,7 @@ export function MenuItem(props: { variant?: 'main' | 'secondary'; menu: Menu; ac ...@@ -40,7 +40,7 @@ export function MenuItem(props: { variant?: 'main' | 'secondary'; menu: Menu; ac
href={normalizedLink} href={normalizedLink}
className={menuItemTriggerClass(variant)} className={menuItemTriggerClass(variant)}
> >
<span className="relative z-10 truncate">{menu.name}</span> <span className={cn("relative z-10", variant === "main" ? "truncate" : "")}>{menu.name}</span>
{variant === 'main' ? <span className="menu-item-underline" aria-hidden="true" /> : null} {variant === 'main' ? <span className="menu-item-underline" aria-hidden="true" /> : null}
</Link> </Link>
) )
...@@ -66,7 +66,7 @@ export function MenuItem(props: { variant?: 'main' | 'secondary'; menu: Menu; ac ...@@ -66,7 +66,7 @@ export function MenuItem(props: { variant?: 'main' | 'secondary'; menu: Menu; ac
function menuItemTriggerClass(variant: 'main' | 'secondary') { function menuItemTriggerClass(variant: 'main' | 'secondary') {
if (variant === 'secondary') { if (variant === 'secondary') {
return cn( return cn(
'inline-flex h-[36px] items-center justify-center rounded-full border border-[#d6dfeb] bg-white px-5 text-[13px] font-medium leading-none text-[#5f6b7d] shadow-none transition-colors duration-150', 'inline-flex min-h-[38px] max-w-[280px] items-center justify-center rounded-full border border-[#d6dfeb] bg-white px-5 py-2 text-center text-[13px] font-medium leading-[1.25] text-[#5f6b7d] shadow-none transition-colors duration-150 sm:max-w-none sm:whitespace-nowrap',
'hover:border-[#c5d2e3] hover:bg-[#f7faff] hover:text-[#1b5aa1]', 'hover:border-[#c5d2e3] hover:bg-[#f7faff] hover:text-[#1b5aa1]',
'aria-selected:border-[#16559d] aria-selected:bg-[#16559d] aria-selected:text-white', 'aria-selected:border-[#16559d] aria-selected:bg-[#16559d] aria-selected:text-white',
'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' 'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
......
...@@ -3,7 +3,11 @@ const DEFAULT_BACKEND_ORIGIN = "https://vietprodev.duckdns.org/gateway/vcci-news ...@@ -3,7 +3,11 @@ const DEFAULT_BACKEND_ORIGIN = "https://vietprodev.duckdns.org/gateway/vcci-news
const normalizeOrigin = (value?: string | null) => value?.trim().replace(/\/+$/, "") || ""; const normalizeOrigin = (value?: string | null) => value?.trim().replace(/\/+$/, "") || "";
const readOrigin = (key: "NEXT_PUBLIC_BACKEND_HOST" | "NEXT_PUBLIC_FRONTEND_HOST") => { const readOrigin = (key: "NEXT_PUBLIC_BACKEND_HOST" | "NEXT_PUBLIC_FRONTEND_HOST") => {
const envOrigin = normalizeOrigin(process.env[key]); const envOrigin = normalizeOrigin(
key === "NEXT_PUBLIC_BACKEND_HOST"
? process.env.NEXT_PUBLIC_BACKEND_HOST
: process.env.NEXT_PUBLIC_FRONTEND_HOST,
);
if (envOrigin) return envOrigin; if (envOrigin) return envOrigin;
if (key === "NEXT_PUBLIC_BACKEND_HOST" && process.env.NODE_ENV === "production") { if (key === "NEXT_PUBLIC_BACKEND_HOST" && process.env.NODE_ENV === "production") {
return DEFAULT_BACKEND_ORIGIN; return DEFAULT_BACKEND_ORIGIN;
......
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