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

Merge branch 'feat/duck' into 'develop-news'

fix

See merge request !55
parents d6e3b0c6 82063bf1
// Core
import { AxiosError, isAxiosError } from 'axios'
import { QueryClient } from '@tanstack/react-query'
// App
// import router from '@/router'
import useAuthStore from '@/store/useAuthStore'
// import useProfileStore from '@stores/profile'
import { QueryData } from '@/lib/types/base-api'
import { AxiosError, isAxiosError } from 'axios'
import { QueryClient } from '@tanstack/react-query'
// App
// import router from '@/router'
import { handleAdminUnauthorized } from '@/lib/auth/admin-auth'
// import useProfileStore from '@stores/profile'
import { QueryData } from '@/lib/types/base-api'
// import { BASE_PATHS } from '@/constants/path'
// Constants
......@@ -40,16 +40,15 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
}
// Handle un authorization error
const handleUnAuthorizationError = () => {
useAuthStore.getState().resetStore()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// })
// router.navigate('')
window.location.href = process ? '/' : '/admin'
}
const handleUnAuthorizationError = () => {
void handleAdminUnauthorized()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// })
// router.navigate('')
}
// Handle delay value
const handleDelayRetry = (failureCount: number) => failureCount * 1000 + Math.random() * 1000
......
import Axios, { AxiosError, AxiosRequestConfig } from "axios";
import Axios, { AxiosError, AxiosHeaders, AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
import {
ensureValidAdminAccessToken,
refreshAdminAccessToken,
} from "@/lib/auth/admin-auth";
interface RetriableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
const createAxiosInstance = () => {
const instance = Axios.create({
......@@ -6,11 +14,17 @@ const createAxiosInstance = () => {
withCredentials: true,
});
instance.interceptors.request.use((config) => {
const token = getPersistedAccessToken();
instance.interceptors.request.use(async (config) => {
if (shouldSkipAuthHandling(config.url)) {
return config;
}
const token = await ensureValidAdminAccessToken().catch(() => null);
if (token && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${token}`;
if (token) {
const headers = AxiosHeaders.from(config.headers);
headers.set("Authorization", `Bearer ${token}`);
config.headers = headers;
}
return config;
......@@ -18,7 +32,36 @@ const createAxiosInstance = () => {
instance.interceptors.response.use(
async (response) => response,
(error) => Promise.reject(error),
async (error: AxiosError) => {
const originalRequest = error.config as RetriableAxiosRequestConfig | undefined;
if (
error.response?.status !== 401 ||
!originalRequest ||
originalRequest._retry ||
shouldSkipAuthHandling(originalRequest.url)
) {
return Promise.reject(error);
}
originalRequest._retry = true;
try {
const nextAccessToken = await refreshAdminAccessToken();
if (!nextAccessToken) {
return Promise.reject(error);
}
const headers = AxiosHeaders.from(originalRequest.headers);
headers.set("Authorization", `Bearer ${nextAccessToken}`);
originalRequest.headers = headers;
return instance(originalRequest);
} catch (refreshError) {
return Promise.reject(refreshError);
}
},
);
return instance;
......@@ -26,23 +69,9 @@ const createAxiosInstance = () => {
const AXIOS_INSTANCE = createAxiosInstance();
const getPersistedAccessToken = () => {
if (typeof window === "undefined") return null;
try {
const rawAuthStorage = window.localStorage.getItem("app-auth-storage");
if (!rawAuthStorage) return null;
const parsedAuthStorage = JSON.parse(rawAuthStorage) as {
state?: {
appAccessToken?: string | null;
};
};
return parsedAuthStorage.state?.appAccessToken ?? null;
} catch {
return null;
}
const shouldSkipAuthHandling = (url?: string | null) => {
if (!url) return false;
return /\/auth\/(login|refresh|logout)(\?|$)/.test(url);
};
const convertHeaders = (headers?: HeadersInit): Record<string, string> | undefined => {
......
'use client';
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
const businessItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
!item.is_hidden &&
(item.category_ids.includes("cat-business-opportunity") ||
item.tagsearch_values.some((tag) => tag.toLowerCase().includes("cơ hội kinh doanh"))),
)
.sort(
(left, right) =>
new Date(right.published_at || right.created_at).getTime() -
new Date(left.published_at || left.created_at).getTime(),
);
function formatPublishDate(item: AdminNewsItem) {
return dayjs(item.published_at || item.created_at).format("DD/MM/YYYY");
}
function BusinessOpportunities() {
const { businessPosts, categoryLinks, categoryNames } = useHomePosts();
const businessItems = businessPosts;
const [featuredItem, ...listItems] = businessItems;
if (!featuredItem) return null;
const listSlots = Array.from({ length: 3 }, (_, index) => listItems[index] ?? null);
const sectionLink =
categoryLinks.get(categoryNames.coHoiKinhDoanh.toLowerCase()) ??
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh";
return (
<section className="flex-1">
......@@ -42,7 +25,7 @@ function BusinessOpportunities() {
</div>
<Link
href="/xuc-tien-thuong-mai/co-hoi/"
href={sectionLink}
className="text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<ChevronRight className="h-5 w-5" />
......@@ -50,32 +33,56 @@ function BusinessOpportunities() {
</div>
<div className="space-y-3">
<Link
href="/xuc-tien-thuong-mai/co-hoi/"
className="block rounded-[18px] bg-[#f5f7fb] px-4 py-3.5 transition-colors hover:bg-[#eef3fb]"
>
<h3 className="line-clamp-2 text-[16px] font-bold leading-[1.45] text-[#264798] md:text-[17px]">
{featuredItem.title}
</h3>
<p className="mt-2 text-[13px] text-[#9aa8c1]">{formatPublishDate(featuredItem)}</p>
</Link>
{featuredItem ? (
<Link
href={featuredItem.externalLink}
className="block rounded-[18px] bg-[#f5f7fb] px-4 py-3.5 transition-colors hover:bg-[#eef3fb]"
>
<h3 className="line-clamp-2 text-[16px] font-bold leading-[1.45] text-[#264798] md:text-[17px]">
{featuredItem.title}
</h3>
<p className="mt-2 text-[13px] text-[#9aa8c1]">
{dayjs(featuredItem.publishedAt || featuredItem.createdAt).format("DD/MM/YYYY")}
</p>
</Link>
) : (
<div className="rounded-[18px] bg-[#f5f7fb] px-4 py-3.5">
<div className="h-6 w-5/6 rounded bg-white" />
<div className="mt-2 h-4 w-24 rounded bg-white/80" />
</div>
)}
<div className="space-y-2.5">
{listItems.slice(0, 3).map((item) => (
<Link
key={item.id}
href="/xuc-tien-thuong-mai/co-hoi/"
className="flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798]">
{item.title}
</h4>
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">{formatPublishDate(item)}</p>
{listSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className="flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798]">
{item.title}
</h4>
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">
{dayjs(item.publishedAt || item.createdAt).format("DD/MM/YYYY")}
</p>
</div>
</Link>
) : (
<div
key={`business-placeholder-${index}`}
className="flex gap-3 rounded-[14px] px-0.5 py-1"
>
<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 rounded bg-[#eef3fb]" />
<div className="mt-1.5 h-4 w-24 rounded bg-[#f4f7fb]" />
</div>
</div>
</Link>
))}
),
)}
</div>
</div>
</section>
......
'use client';
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { useHomePosts, type HomePostItem } from "@/app/(main)/(home)/lib/use-home-posts";
import { addMonths, format, getDay, startOfMonth, subMonths } from "date-fns";
import dayjs from "dayjs";
import { ChevronLeft, ChevronRight } from "lucide-react";
......@@ -11,41 +8,32 @@ import { useMemo, useState } from "react";
const weekDays = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
const eventItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
!item.is_hidden &&
item.started_at,
)
.sort(
(left, right) =>
new Date(left.started_at).getTime() - new Date(right.started_at).getTime(),
);
function isTrainingEvent(item: AdminNewsItem) {
return item.tagsearch_values.some((tag) => tag.toLowerCase().includes("đào tạo"));
}
function EventsCalendar() {
const firstEventDate = eventItems[0]?.started_at
? new Date(eventItems[0].started_at)
const { eventPosts } = useHomePosts();
const firstEventDate = eventPosts[0]?.startedAt
? new Date(eventPosts[0].startedAt)
: new Date("2026-11-01T00:00:00");
const [currentMonth, setCurrentMonth] = useState(
new Date(firstEventDate.getFullYear(), firstEventDate.getMonth(), 1),
);
const isTrainingEvent = (item: HomePostItem) =>
item.categories.some((category) =>
category.name.toLowerCase().includes("đào tạo"),
);
const monthEvents = useMemo(
() =>
eventItems.filter((item) => {
const date = new Date(item.started_at);
eventPosts.filter((item) => {
const date = new Date(item.startedAt);
return (
date.getMonth() === currentMonth.getMonth() &&
date.getFullYear() === currentMonth.getFullYear()
);
}),
[currentMonth],
[currentMonth, eventPosts],
);
const days = useMemo(() => {
......@@ -62,10 +50,10 @@ function EventsCalendar() {
}, [currentMonth]);
const eventMap = useMemo(() => {
const map = new Map<string, AdminNewsItem[]>();
const map = new Map<string, HomePostItem[]>();
monthEvents.forEach((item) => {
const key = dayjs(item.started_at).format("YYYY-MM-DD");
const key = dayjs(item.startedAt).format("YYYY-MM-DD");
const existing = map.get(key) ?? [];
existing.push(item);
map.set(key, existing);
......
'use client';
import ImageNext from "@/components/shared/image-next";
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import Link from "next/link";
const eventItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
item.header_category_id === "activity-events" &&
!item.is_hidden &&
item.started_at,
)
.sort(
(left, right) =>
new Date(left.started_at).getTime() - new Date(right.started_at).getTime(),
);
function formatEventDate(item: AdminNewsItem) {
return dayjs(item.started_at || item.published_at || item.created_at).format("DD/MM/YYYY");
}
function Events() {
const { eventPosts, categoryLinks, categoryNames } = useHomePosts();
const eventItems = eventPosts;
const [featuredEvent, ...sideEvents] = eventItems;
if (!featuredEvent) return null;
const sideSlots = Array.from({ length: 4 }, (_, index) => sideEvents[index] ?? null);
const eventsLink =
categoryLinks.get(categoryNames.suKien.toLowerCase()) ?? "/hoat-dong/su-kien";
return (
<div className="flex-1 rounded-[28px] 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">
......@@ -41,7 +24,7 @@ function Events() {
</div>
<Link
href="/hoat-dong/su-kien"
href={eventsLink}
className="pt-1.5 text-sm font-semibold text-[#ffd34f] transition-colors hover:text-white"
>
Xem sự kiện
......@@ -49,53 +32,84 @@ function Events() {
</div>
<div className="grid items-stretch gap-3 xl:grid-cols-[minmax(0,1.02fr)_minmax(270px,0.98fr)]">
<Link
href="/hoat-dong/su-kien"
className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
>
<div className="h-[220px] overflow-hidden md:h-[235px] xl:h-[248px]">
<ImageNext
src={featuredEvent.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720}
height={520}
className="h-full w-full object-cover"
/>
</div>
{featuredEvent ? (
<Link
href={featuredEvent.externalLink}
className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
>
<div className="h-[220px] overflow-hidden md:h-[235px] xl:h-[248px]">
<ImageNext
src={featuredEvent.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720}
height={520}
className="h-full w-full object-cover"
/>
</div>
<div className="p-3 pt-2.5">
<h3 className="line-clamp-2 text-[16px] font-extrabold uppercase leading-[1.28] text-[#22459b] md:text-[18px]">
{featuredEvent.title}
</h3>
<p className="mt-1.5 text-[13px] text-[#90a0bd]">{formatEventDate(featuredEvent)}</p>
<div className="p-3 pt-2.5">
<h3 className="line-clamp-2 text-[16px] font-extrabold uppercase leading-[1.28] text-[#22459b] md:text-[18px]">
{featuredEvent.title}
</h3>
<p className="mt-1.5 text-[13px] text-[#90a0bd]">
{dayjs(
featuredEvent.startedAt || featuredEvent.publishedAt || featuredEvent.createdAt,
).format("DD/MM/YYYY")}
</p>
</div>
</Link>
) : (
<div className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.12)]">
<div className="h-[220px] bg-[#d7e3f9] md:h-[235px] xl:h-[248px]" />
<div className="space-y-2 p-3 pt-2.5">
<div className="h-6 w-5/6 rounded bg-[#e7eefb]" />
<div className="h-4 w-24 rounded bg-[#eef3fb]" />
</div>
</div>
</Link>
)}
<div className="flex h-full flex-col gap-3">
{sideEvents.slice(0, 4).map((item) => (
<Link
key={item.id}
href="/hoat-dong/su-kien"
className="flex flex-1 items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
>
<div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[12px]">
<ImageNext
src={item.thumbnail?.url ?? "/thumbnail.png"}
alt={item.thumbnail?.alt || item.title}
width={160}
height={160}
className="h-full w-full object-cover"
/>
</div>
{sideSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className="flex flex-1 items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
>
<div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[12px]">
<ImageNext
src={item.thumbnail?.url ?? "/thumbnail.png"}
alt={item.thumbnail?.alt || item.title}
width={160}
height={160}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] font-semibold leading-[1.35] text-white">
{item.title}
</h4>
<p className="mt-1 text-[12px] text-white/78">{formatEventDate(item)}</p>
<div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] font-semibold leading-[1.35] text-white">
{item.title}
</h4>
<p className="mt-1 text-[12px] text-white/78">
{dayjs(item.startedAt || item.publishedAt || item.createdAt).format(
"DD/MM/YYYY",
)}
</p>
</div>
</Link>
) : (
<div
key={`event-placeholder-${index}`}
className="flex flex-1 items-center gap-3 rounded-[18px] 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 rounded-[12px] bg-white/20" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-white/25" />
<div className="mt-2 h-3 w-20 rounded bg-white/20" />
</div>
</div>
</Link>
))}
),
)}
</div>
</div>
</div>
......
'use client';
import ImageNext from "@/components/shared/image-next";
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { getHeaderCategorySeed } from "@/mockdata/header-config";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight, Mail, Phone } from "lucide-react";
import Link from "next/link";
const FALLBACK_CATEGORY_LINK = "/hoat-dong/tin-tuc";
const headerCategoryMap = new Map(
getHeaderCategorySeed().map((item) => [item.id, item.static_link]),
);
const FEATURED_OVERVIEW_LINK =
headerCategoryMap.get("activity-news") ?? FALLBACK_CATEGORY_LINK;
function getFeaturedNewsItems(items: AdminNewsItem[]) {
return items
.filter(
(item) =>
item.type === "tintuc" &&
item.is_featured &&
!item.is_hidden &&
Boolean(item.thumbnail?.url),
)
.slice(0, 3);
}
function getNewsLink(item: AdminNewsItem) {
return headerCategoryMap.get(item.header_category_id) ?? FALLBACK_CATEGORY_LINK;
}
function getBadgeLabel(item: AdminNewsItem) {
if (item.header_category_id === "activity-events") return "Sự kiện";
const firstTag = item.tagsearch_values.find(Boolean);
if (firstTag) return firstTag;
return "Tin VCCI";
}
const featuredNewsItems = getFeaturedNewsItems(getAdminNewsSeed());
function FeaturedNews() {
const { featuredPosts, categoryNames, categoryLinks } = useHomePosts();
const featuredNewsItems = featuredPosts.slice(0, 3);
const [primaryItem, ...secondaryItems] = featuredNewsItems;
if (!primaryItem) return null;
const secondarySlots = Array.from({ length: 2 }, (_, index) => secondaryItems[index] ?? null);
const featuredOverviewLink =
categoryLinks.get(categoryNames.tinVcci.toLowerCase()) ?? FALLBACK_CATEGORY_LINK;
return (
<section className="py-8 md:py-10">
......@@ -61,7 +28,7 @@ function FeaturedNews() {
</div>
<Link
href={FEATURED_OVERVIEW_LINK}
href={featuredOverviewLink}
className="inline-flex items-center gap-2 pt-2 text-base font-semibold text-[#2b56c0] transition-colors hover:text-[#173f9f]"
>
<span>Xem tất cả</span>
......@@ -70,70 +37,97 @@ function FeaturedNews() {
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.14fr)_minmax(0,0.96fr)]">
<Link
href={getNewsLink(primaryItem)}
className="group relative block min-h-[260px] overflow-hidden rounded-[24px] bg-[#0d2f5f] shadow-[0_18px_38px_rgba(28,52,120,0.22)] md:min-h-[320px] xl:min-h-[350px]"
>
<div className="relative h-full min-h-[260px] md:min-h-[320px] xl:min-h-[350px]">
<ImageNext
src={primaryItem.thumbnail?.url ?? "/thumbnail.png"}
alt={primaryItem.thumbnail?.alt || primaryItem.title}
width={1200}
height={800}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
<div className="absolute inset-0 bg-linear-to-t from-[#26356d] via-[#53669b]/34 to-transparent" />
<div className="relative flex h-full flex-col justify-end p-4 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]">
{getBadgeLabel(primaryItem)}
</span>
<h3 className="max-w-3xl text-[20px] font-bold leading-[1.28] text-white md:text-[28px] xl:text-[32px]">
{primaryItem.title}
</h3>
<p className="mt-2 text-base font-medium text-white/78 md:text-[17px]">
{dayjs(primaryItem.published_at || primaryItem.created_at).format("DD/MM/YYYY")}
</p>
{primaryItem ? (
<Link
href={primaryItem.externalLink}
className="group relative block min-h-[260px] overflow-hidden rounded-[24px] bg-[#0d2f5f] shadow-[0_18px_38px_rgba(28,52,120,0.22)] md:min-h-[320px] xl:min-h-[350px]"
>
<div className="relative h-full min-h-[260px] md:min-h-[320px] xl:min-h-[350px]">
<ImageNext
src={primaryItem.thumbnail?.url ?? "/thumbnail.png"}
alt={primaryItem.thumbnail?.alt || primaryItem.title}
width={1200}
height={800}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
<div className="absolute inset-0 bg-linear-to-t from-[#26356d] via-[#53669b]/34 to-transparent" />
<div className="relative flex h-full flex-col justify-end p-4 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"}
</span>
<h3 className="max-w-3xl text-[20px] font-bold leading-[1.28] text-white md:text-[28px] xl:text-[32px]">
{primaryItem.title}
</h3>
<p className="mt-2 text-base font-medium text-white/78 md:text-[17px]">
{dayjs(primaryItem.publishedAt || primaryItem.createdAt).format(
"DD/MM/YYYY",
)}
</p>
</div>
</div>
</Link>
) : (
<div className="relative min-h-[260px] overflow-hidden rounded-[24px] bg-[#e9eef8] shadow-[0_18px_38px_rgba(28,52,120,0.12)] md:min-h-[320px] xl:min-h-[350px]">
<div className="flex h-full min-h-[260px] flex-col justify-end p-4 md:min-h-[320px] md:p-5 xl:min-h-[350px]">
<span className="mb-2 h-8 w-28 rounded-[10px] bg-white/80" />
<div className="h-8 w-3/4 rounded bg-white/90 md:h-10" />
<div className="mt-2 h-5 w-28 rounded bg-white/70" />
</div>
</div>
</Link>
)}
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
{secondaryItems.map((item) => (
<Link
key={item.id}
href={getNewsLink(item)}
className="group relative block min-h-[195px] overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:min-h-[205px] xl:min-h-[215px]"
>
<div className="relative h-full min-h-[195px] md:min-h-[205px] xl:min-h-[215px]">
<ImageNext
src={item.thumbnail?.url ?? "/thumbnail.png"}
alt={item.thumbnail?.alt || item.title}
width={600}
height={420}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.05]"
/>
<div className="absolute inset-0 bg-linear-to-t from-[#5a6796] via-[#405083]/34 to-transparent" />
<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]">
{getBadgeLabel(item)}
</span>
<h4 className="line-clamp-2 text-[16px] font-bold leading-[1.32] text-white md:text-[17px]">
{item.title}
</h4>
<p className="mt-1.5 text-[15px] font-medium text-white/78 md:text-base">
{dayjs(item.published_at || item.created_at).format("DD/MM/YYYY")}
</p>
{secondarySlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className="group relative block min-h-[195px] overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:min-h-[205px] xl:min-h-[215px]"
>
<div className="relative h-full min-h-[195px] md:min-h-[205px] xl:min-h-[215px]">
<ImageNext
src={item.thumbnail?.url ?? "/thumbnail.png"}
alt={item.thumbnail?.alt || item.title}
width={600}
height={420}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.05]"
/>
<div className="absolute inset-0 bg-linear-to-t from-[#5a6796] via-[#405083]/34 to-transparent" />
<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"}
</span>
<h4 className="line-clamp-2 text-[16px] font-bold leading-[1.32] text-white md:text-[17px]">
{item.title}
</h4>
<p className="mt-1.5 text-[15px] font-medium text-white/78 md:text-base">
{dayjs(item.publishedAt || item.createdAt).format(
"DD/MM/YYYY",
)}
</p>
</div>
</div>
</Link>
) : (
<div
key={`featured-placeholder-${index}`}
className="min-h-[195px] rounded-[20px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] md:min-h-[205px] xl:min-h-[215px]"
>
<div className="flex h-full min-h-[195px] flex-col justify-end p-3.5 md:min-h-[205px] xl:min-h-[215px]">
<span className="mb-2 h-7 w-24 rounded-[10px] bg-white/80" />
<div className="h-6 w-5/6 rounded bg-white/90" />
<div className="mt-2 h-4 w-24 rounded bg-white/70" />
</div>
</div>
</Link>
))}
),
)}
</div>
<div className="overflow-hidden rounded-[28px] bg-linear-to-r from-[#214b95] to-[#2b66bb] px-5 py-5 text-white shadow-[0_18px_38px_rgba(28,52,120,0.2)] md:px-7">
......
'use client';
import ImageNext from "@/components/shared/image-next";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import memberImages from "@/constants/memberImages";
import {
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import Link from "next/link";
const memberConnectionItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
!item.is_hidden &&
(item.category_ids.includes("cat-member-connection") ||
item.tagsearch_values.some((tag) => tag.toLowerCase().includes("kết nối hội viên"))),
)
.sort(
(left, right) =>
new Date(right.published_at || right.created_at).getTime() -
new Date(left.published_at || left.created_at).getTime(),
);
function Members() {
const featuredConnection = memberConnectionItems[0];
const { memberConnectionPosts, categoryLinks, categoryNames } = useHomePosts();
const featuredConnection = memberConnectionPosts[0];
const sectionLink =
categoryLinks.get(categoryNames.ketNoiHoiVien.toLowerCase()) ?? "/hoi-vien/ket-noi-hoi-vien";
return (
<section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch">
......@@ -36,7 +23,7 @@ function Members() {
</div>
<Link
href="/danh-ba-hoi-vien"
href={sectionLink}
className="pt-1 text-sm font-semibold text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
Xem thêm
......@@ -77,7 +64,7 @@ function Members() {
{featuredConnection ? (
<Link
href="/danh-ba-hoi-vien"
href={featuredConnection.externalLink}
className="block overflow-hidden rounded-[20px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[1.25/1] overflow-hidden rounded-[20px]">
......@@ -90,7 +77,11 @@ function Members() {
/>
</div>
</Link>
) : null}
) : (
<div className="overflow-hidden rounded-[20px] bg-[#eef3fb] shadow-[0_16px_32px_rgba(31,59,124,0.08)]">
<div className="aspect-[1.25/1] rounded-[20px] bg-[#e3ebf8]" />
</div>
)}
</aside>
</section>
);
......
'use client';
import ImageNext from "@/components/shared/image-next";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import stripImagesAndHtml from "@/helpers/stripImageAndHtml";
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import dayjs from "dayjs";
import Link from "next/link";
import { useMemo, useState } from "react";
......@@ -13,60 +10,32 @@ import { useMemo, useState } from "react";
const tabs = [
{ id: "all", label: "Tất cả" },
{ id: "tin-vcci", label: "Tin VCCI" },
{ id: "tin-kinh-te", label: "Tin Kinh Tế" },
{ id: "chuyen-de", label: "Chuyên Đề" },
{ id: "tin-kinh-te", label: "Tin Kinh tế" },
{ id: "chuyen-de", label: "Chuyên đề" },
];
const allNewsItems = getAdminNewsSeed().filter(
(item) => item.type === "tintuc" && !item.is_hidden,
);
function getTabLabel(item: AdminNewsItem) {
const tags = item.tagsearch_values.map((tag) => tag.toLowerCase());
if (tags.some((tag) => tag.includes("kinh tế") || tag.includes("vĩ mô"))) {
return "Tin Kinh Tế";
}
if (tags.some((tag) => tag.includes("chuyên đề") || tag.includes("cẩm nang"))) {
return "Chuyên Đề";
}
return "Tin VCCI";
}
function matchesTab(item: AdminNewsItem, tab: string) {
if (tab === "all") return true;
const tags = item.tagsearch_values.map((value) => value.toLowerCase());
if (tab === "tin-vcci") {
return tags.some((tag) => tag.includes("tin vcci") || tag.includes("hợp tác"));
}
if (tab === "tin-kinh-te") {
return tags.some((tag) => tag.includes("kinh tế") || tag.includes("vĩ mô"));
}
if (tab === "chuyen-de") {
return tags.some((tag) => tag.includes("chuyên đề") || tag.includes("cẩm nang"));
}
return true;
}
function News() {
const [tab, setTab] = useState("all");
const { newsTabs, categoryLinks, categoryNames } = useHomePosts();
const filteredItems = useMemo(
() => allNewsItems.filter((item) => matchesTab(item, tab)),
[tab],
);
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]);
const featuredArticle = filteredItems[0] ?? allNewsItems[0];
const featuredArticle = filteredItems[0] ?? newsTabs.all[0];
const listArticles = filteredItems.slice(1, 5);
if (!featuredArticle) return null;
const listSlots = Array.from({ length: 4 }, (_, index) => listArticles[index] ?? null);
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">
......@@ -102,59 +71,93 @@ function News() {
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.02fr)_minmax(320px,0.98fr)]">
<div>
<Link
href="/hoat-dong/tin-tuc"
className="block h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="aspect-[1.75/1] overflow-hidden">
<ImageNext
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
height={580}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1.5 p-3">
<span className="inline-flex text-[14px] font-bold text-[#e2a500]">
{getTabLabel(featuredArticle)}
</span>
<h3 className="line-clamp-2 text-[16px] font-bold leading-[1.28] text-[#20408f] md:text-[17px]">
{featuredArticle.title}
</h3>
<p className="line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]">
{stripImagesAndHtml(featuredArticle.summary)}
</p>
{featuredArticle ? (
<Link
href={featuredArticle.externalLink}
className="block h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="aspect-[1.75/1] overflow-hidden">
<ImageNext
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
height={580}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1.5 p-3">
<span className="inline-flex text-[14px] font-bold text-[#e2a500]">
{featuredArticle.categories[0]?.name || "Tin tức"}
</span>
<h3 className="line-clamp-2 text-[16px] font-bold leading-[1.28] text-[#20408f] md:text-[17px]">
{featuredArticle.title}
</h3>
<p className="line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]">
{stripImagesAndHtml(featuredArticle.summary)}
</p>
<p className="text-[14px] text-[#8a9bb6]">
{dayjs(featuredArticle.published_at || featuredArticle.created_at).format("DD/MM/YYYY")}
</p>
<p className="text-[14px] text-[#8a9bb6]">
{dayjs(featuredArticle.publishedAt || featuredArticle.createdAt).format(
"DD/MM/YYYY",
)}
</p>
</div>
</Link>
) : (
<div className="h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]">
<div className="aspect-[1.75/1] bg-[#eef3fb]" />
<div className="space-y-2 p-3">
<div className="h-5 w-24 rounded bg-[#eef3fb]" />
<div className="h-6 w-5/6 rounded bg-[#eef3fb]" />
<div className="h-4 w-full rounded bg-[#f4f7fb]" />
<div className="h-4 w-3/4 rounded bg-[#f4f7fb]" />
<div className="h-4 w-24 rounded bg-[#eef3fb]" />
</div>
</div>
</Link>
)}
</div>
<div className="xl:flex xl:h-full xl:flex-col">
<div className="space-y-3 xl:flex xl:flex-1 xl:flex-col">
{listArticles.map((news) => (
<Link
key={news.id}
href="/hoat-dong/tin-tuc"
className="block rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)] xl:flex-1"
>
<h4 className="line-clamp-2 text-[15px] font-bold leading-[1.28] text-[#21408f]">
{news.title}
</h4>
<p className="mt-1 text-[13px] text-[#8a9bb6]">
{dayjs(news.published_at || news.created_at).format("DD/MM/YYYY")}
</p>
</Link>
))}
{listSlots.map((news, index) =>
news ? (
<Link
key={news.id}
href={news.externalLink}
className="block rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)] xl:flex-1"
>
<h4 className="line-clamp-2 text-[15px] font-bold leading-[1.28] text-[#21408f]">
{news.title}
</h4>
<p className="mt-1 text-[13px] text-[#8a9bb6]">
{dayjs(news.publishedAt || news.createdAt).format("DD/MM/YYYY")}
</p>
</Link>
) : (
<div
key={`news-placeholder-${index}`}
className="rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.06)] xl:flex-1"
>
<div className="h-5 w-5/6 rounded bg-[#eef3fb]" />
<div className="mt-2 h-4 w-24 rounded bg-[#f4f7fb]" />
</div>
),
)}
</div>
</div>
</div>
<div className="mt-3 flex justify-end">
<Link
href={overviewLink}
className="text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
Xem tất cả
</Link>
</div>
</div>
);
}
......
'use client';
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
const policyItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
!item.is_hidden &&
(item.category_ids.includes("cat-policy-law") ||
item.category_ids.includes("cat-policy") ||
item.tagsearch_values.some((tag) => {
const normalized = tag.toLowerCase();
return normalized.includes("chính sách") || normalized.includes("pháp luật");
})),
)
.sort(
(left, right) =>
new Date(right.published_at || right.created_at).getTime() -
new Date(left.published_at || left.created_at).getTime(),
);
function formatPublishDate(item: AdminNewsItem) {
return dayjs(item.published_at || item.created_at).format("DD/MM/YYYY");
}
function PolicyAndLaws() {
const { policyPosts, categoryLinks, categoryNames } = useHomePosts();
const policyItems = policyPosts;
const [featuredItem, ...listItems] = policyItems;
if (!featuredItem) return null;
const listSlots = [featuredItem, ...listItems.slice(0, 2)];
const sectionLink =
categoryLinks.get(categoryNames.chinhSachPhapLuat.toLowerCase()) ??
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat";
return (
<section className="flex-1">
......@@ -46,7 +25,7 @@ function PolicyAndLaws() {
</div>
<Link
href="/thong-tin-truyen-thong/phap-luat"
href={sectionLink}
className="text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<ChevronRight className="h-5 w-5" />
......@@ -54,23 +33,38 @@ function PolicyAndLaws() {
</div>
<div className="space-y-2.5">
{[featuredItem, ...listItems.slice(0, 2)].map((item, index) => (
<Link
key={item.id}
href="/thong-tin-truyen-thong/phap-luat"
className={`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
index === 0 ? "pt-0.5" : ""
}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
<h3 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798] md:text-[16px]">
{item.title}
</h3>
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">{formatPublishDate(item)}</p>
{listSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className={`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
index === 0 ? "pt-0.5" : ""
}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
<h3 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798] md:text-[16px]">
{item.title}
</h3>
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">
{dayjs(item.publishedAt || item.createdAt).format("DD/MM/YYYY")}
</p>
</div>
</Link>
) : (
<div
key={`policy-placeholder-${index}`}
className={`flex gap-3 rounded-[14px] 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 rounded bg-[#eef3fb]" />
<div className="mt-1.5 h-4 w-24 rounded bg-[#f4f7fb]" />
</div>
</div>
</Link>
))}
),
)}
</div>
</section>
);
......
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useCustomClient } from "@/api/mutator/custom-client";
import Links from "@/links";
type RawHomeCategory = {
id?: string | null;
name?: string | null;
url?: string | null;
type?: string | null;
};
type RawHomeThumbnail = {
path?: string | null;
original?: string | null;
};
type RawHomePost = {
id?: string | null;
title?: string | null;
external_link?: string | null;
summary?: string | null;
content?: string | null;
release_at?: string | null;
published_at?: string | null;
created_at?: string | null;
started_at?: string | null;
expired_at?: string | null;
is_featured?: boolean | null;
is_hidden?: boolean | null;
is_active?: boolean | null;
status?: string | null;
type?: string | null;
categories?: RawHomeCategory[] | null;
thumbnail?: RawHomeThumbnail | null;
};
type HomeEnvelope<T> = {
responseData?: T;
};
type HomePagedResult<T> = {
rows?: T[];
};
export type HomePostCategory = {
id: string;
name: string;
url: string;
type: string;
};
export type HomePostItem = {
id: string;
title: string;
externalLink: string;
summary: string;
createdAt: string;
publishedAt: string;
startedAt: string;
expiredAt: string;
isFeatured: boolean;
isHidden: boolean;
isActive: boolean;
status: string;
type: string;
categories: HomePostCategory[];
thumbnail: {
url: string;
alt: string;
} | null;
};
const HOME_POSTS_QUERY_KEY = ["home-page-posts"] as const;
const HOME_CATEGORY_NAMES = {
tinVcci: "Tin VCCI",
tinKinhTe: "Tin Kinh tế",
chuyenDe: "Chuyên đề",
suKien: "Sự kiện",
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 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 resolveAssetUrl = (value?: string | null) => {
const trimmed = value?.trim();
if (!trimmed) return "/thumbnail.png";
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed;
if (trimmed.startsWith("/")) {
return `${Links.imageEndpoint.replace(/\/+$/, "")}${trimmed}`;
}
return `${Links.imageEndpoint}${trimmed.replace(/^\/+/, "")}`;
};
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 isVisibleNewsPost = (item: HomePostItem) => {
if (item.type && item.type !== "news") return false;
if (item.isHidden) return false;
if (!item.isActive) return false;
if (item.status && item.status !== "published") return false;
return true;
};
async function fetchHomePosts() {
const query = new URLSearchParams({
page: "1",
pageSize: "200",
sortField: "created_at",
sortOrder: "desc",
});
const response = await useCustomClient<HomeEnvelope<HomePagedResult<RawHomePost>>>(
`/post?${query.toString()}`,
);
const rows = response.responseData?.rows ?? [];
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),
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 = normalizeLink(
item.external_link || (title ? `/${title}` : undefined),
"#",
);
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 ?? ""),
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,
};
})
.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) => matchesCategoryName(item, HOME_CATEGORY_NAMES.tinVcci)),
),
[posts],
);
const tinKinhTePosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => matchesCategoryName(item, HOME_CATEGORY_NAMES.tinKinhTe)),
),
[posts],
);
const chuyenDePosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => matchesCategoryName(item, HOME_CATEGORY_NAMES.chuyenDe)),
),
[posts],
);
const featuredPosts = React.useMemo(
() =>
sortByPublishedDesc(
allPosts.filter((item) => item.isFeatured && !item.isHidden),
),
[allPosts],
);
const eventPosts = React.useMemo(
() =>
sortByEventStartAsc(
posts.filter(
(item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.suKien) &&
Boolean(item.startedAt),
),
),
[posts],
);
const businessPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.coHoiKinhDoanh),
),
),
[posts],
);
const policyPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.chinhSachPhapLuat),
),
),
[posts],
);
const memberConnectionPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.ketNoiHoiVien),
),
),
[posts],
);
const allNewsPosts = React.useMemo(
() => uniquePosts([...tinVcciPosts, ...tinKinhTePosts, ...chuyenDePosts]),
[chuyenDePosts, tinKinhTePosts, tinVcciPosts],
);
return {
...query,
allPosts,
posts,
featuredPosts,
eventPosts,
businessPosts,
policyPosts,
memberConnectionPosts,
newsTabs: {
all: allNewsPosts,
tinVcci: tinVcciPosts,
tinKinhTe: tinKinhTePosts,
chuyenDe: chuyenDePosts,
},
categoryLinks,
categoryNames: HOME_CATEGORY_NAMES,
};
}
"use client";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { notFound, useParams, useRouter } from "next/navigation";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config";
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config";
// templates
import InformationPage from "./templates/InformationPage";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui";
import ArticlePage from "./templates/ArticlePage";
import ArticleDetailPage from "./templates/ArticleDetailPage";
import EventPage from "./templates/EventPage";
import EventDetailPage from "./templates/EventDetailPage";
import { Spinner } from "@/components/ui";
import { GetNewsResponseType } from "@/api/types/news";
import { useGetNews } from "@/api/endpoints/news";
import InformationPage from "./templates/InformationPage";
import {
fetchDynamicCategories,
fetchDynamicPostByExternalLink,
fetchDynamicSinglePagePost,
findDynamicCategoryByPath,
findFirstChildCategory,
findMenuCategoryForPost,
} from "./templates/data";
export default function DynamicPage() {
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/");
const routePath = `/${path}`;
const router = useRouter();
// query
const { data: news } = useGetNews<GetNewsResponseType>(
{ filters: `external_link==/${path}` }
const categoryQuery = useQuery({
queryKey: ["dynamic-categories"],
queryFn: fetchDynamicCategories,
staleTime: 5 * 60 * 1000,
});
const detailQuery = useQuery({
queryKey: ["dynamic-post-detail", routePath],
queryFn: () => fetchDynamicPostByExternalLink(routePath),
enabled: Boolean(routePath),
staleTime: 60 * 1000,
});
const matchedCategory = useMemo(
() => findDynamicCategoryByPath(categoryQuery.data ?? [], routePath),
[categoryQuery.data, routePath],
);
const resolvedCategory = useMemo(
() => matchedCategory ?? findMenuCategoryForPost(detailQuery.data ?? null, categoryQuery.data ?? []),
[matchedCategory, detailQuery.data, categoryQuery.data],
);
const { data: category, isLoading, isError } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
static_link: `/${path}`,
const singlePageQuery = useQuery({
queryKey: ["dynamic-single-page-post", resolvedCategory?.id],
queryFn: () => fetchDynamicSinglePagePost(resolvedCategory!.id),
enabled: resolvedCategory?.type === "page",
staleTime: 60 * 1000,
});
// redirect to first child if has children
const children = category?.responseData?.children || [];
useEffect(() => {
if (!category) return;
if (slug.length === 1 && children.length > 0) {
const firstChild = children[0];
if (firstChild?.static_link) {
router.push(firstChild.static_link);
}
if (!matchedCategory || matchedCategory.type !== "category") return;
const firstChild = findFirstChildCategory(matchedCategory, categoryQuery.data ?? []);
if (slug.length === 1 && firstChild?.url) {
router.replace(firstChild.url);
}
}, [slug, category, children, router]);
}, [matchedCategory, categoryQuery.data, router, slug.length]);
//template
if (slug[0] === "hoat-dong" && slug[1] === "su-kien") {
if (slug.length === 2) return <EventPage />;
if (slug.length === 3) return <EventDetailPage />;
}
const isLoading =
categoryQuery.isLoading ||
detailQuery.isLoading ||
(resolvedCategory?.type === "page" && singlePageQuery.isLoading);
if (news?.responseData?.count == 0 && isLoading) {
if (isLoading) {
return (
<div className="flex justify-center items-center w-full h-64">
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
if (news && news?.responseData.rows.length !== 0) {
return <ArticleDetailPage data={news} />;
if (detailQuery.data) {
return (
<ArticleDetailPage
post={detailQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
else if (category?.responseData.is_article == true) {
return <ArticlePage />;
if (resolvedCategory?.type === "page") {
if (!singlePageQuery.data) return notFound();
return (
<InformationPage
post={singlePageQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
else if (category?.responseData.is_article == false) {
return <InformationPage />;
if (resolvedCategory?.type === "news") {
return (
<ArticlePage
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
else if (isError) {
return notFound();
if (resolvedCategory?.type === "category") {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
}
\ No newline at end of file
return notFound();
}
'use client';
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config";
import ListCategory from "@/components/base/list-category";
import { useParams } from "next/dist/client/components/navigation";
import { GetNewsResponseType } from "@/api/types/news";
import EventCalendar from "@/components/base/event-calendar";
import dayjs from "dayjs";
import parse from "html-react-parser";
import EventCalendar from "@/components/base/event-calendar";
import ListCategory from "@/components/base/list-category";
import {
buildDynamicCategoryMenu,
getDynamicPostBodyHtml,
} from "./data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
export default function ArticleDetailPage({ data }: { data: GetNewsResponseType }) {
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
type ArticleDetailPageProps = {
post: DynamicPostItem;
category: DynamicCategoryRouteItem | null;
allCategories: DynamicCategoryRouteItem[];
};
//query
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
code: slug[0],
});
export default function ArticleDetailPage({
post,
category,
allCategories,
}: ArticleDetailPageProps) {
const categoryMenu = category
? buildDynamicCategoryMenu(category, allCategories)
: [];
const children = category?.responseData?.children ?? [];
// template
return (
<div className='container w-full flex justify-center items-center pb-10'>
<div className='flex flex-col gap-5 w-full'>
{children.length !== 0 ? (
<ListCategory categories={children} />
) : (
<br />
)}
<div className="container w-full flex justify-center items-center pb-10">
<div className="flex flex-col gap-5 w-full">
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<main className="lg:col-span-2 bg-white border rounded-md p-8">
<div className='pb-5 text-primary text-2xl leading-normal font-medium'>
{data?.responseData?.rows[0]?.title}
<div className="pb-5 text-primary text-2xl leading-normal font-medium">
{post.title}
</div>
<div className='flex items-center gap-2 text-sm mb-4'>
<span className='text-base text-blue-700'>
{dayjs(data?.responseData?.rows[0]?.created_at).format('DD/MM/YYYY')}
<div className="flex items-center gap-2 text-sm mb-4">
<span className="text-base text-blue-700">
{dayjs(post.release_at ?? post.published_at ?? post.created_at).format("DD/MM/YYYY")}
</span>
</div>
<hr className="my-5" />
<div className='flex-1 text-app-grey text-base overflow-hidden'>
<div className="prose tiptap overflow-hidden">
{parse(data?.responseData?.rows[0]?.description ?? '')}
<div className="flex-1 text-app-grey text-base overflow-hidden">
<div className="prose tiptap max-w-none overflow-hidden">
{parse(getDynamicPostBodyHtml(post))}
</div>
</div>
</main>
......@@ -52,4 +53,4 @@ export default function ArticleDetailPage({ data }: { data: GetNewsResponseType
</div>
</div>
);
}
\ No newline at end of file
}
'use client';
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config";
import ListCategory from "@/components/base/list-category";
import { useParams, useSearchParams, useRouter, usePathname } from "next/navigation";
import { useGetNews } from "@/api/endpoints/news";
import { GetNewsResponseType } from "@/api/types/news";
import CardNews from "@/components/base/card-news";
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import ListFilter from "@/components/base/list-filter";
import EventCalendar from "@/components/base/event-calendar";
import { useState, useEffect } from "react";
import { Spinner } from "@/components/ui";
import ListCategory from "@/components/base/list-category";
import {
buildDynamicCategoryMenu,
buildPostFilters,
fetchDynamicPostList,
stripHtml,
} from "./data";
import type { DynamicCategoryRouteItem } from "./types";
import CardNews from "@/components/base/card-news";
export default function ArticlePage() {
// get url
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/");
type ArticlePageProps = {
category: DynamicCategoryRouteItem;
allCategories: DynamicCategoryRouteItem[];
};
export default function ArticlePage({ category, allCategories }: ArticlePageProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParamsString = searchParams.toString();
// states
const initialPage = Number(searchParams.get("page") ?? "1");
const [submitSearch, setSubmitSearch] = useState("");
const [page, setPage] = useState(initialPage);
const pageSize = 5;
const pageSize = 6;
const keyword = submitSearch.trim();
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
const params = new URLSearchParams(searchParamsString);
if (page > 1) {
params.set("page", String(page));
} else {
params.delete("page");
}
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [page]);
const nextUrl = qs ? `${pathname}?${qs}` : pathname;
const currentUrl = searchParamsString ? `${pathname}?${searchParamsString}` : pathname;
// query
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
code: slug[0],
});
if (nextUrl !== currentUrl) {
router.replace(nextUrl, { scroll: false });
}
}, [page, pathname, router, searchParamsString]);
const { data: articles, isLoading: articlesLoading } = useGetNews<GetNewsResponseType>({
filters: `page_config.static_link==/${path}` + (submitSearch ? `,title@=${submitSearch}` : ""),
pageSize: String(pageSize),
currentPage: String(page),
useEffect(() => {
setPage(1);
}, [submitSearch, category.id]);
const postsQuery = useQuery({
queryKey: ["dynamic-posts", category.id, page, pageSize, keyword],
queryFn: () =>
fetchDynamicPostList({
page,
pageSize,
filters: buildPostFilters([
`category.id==${category.id}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
keyword ? `title@=${keyword}` : null,
]),
}),
staleTime: 60 * 1000,
});
const children = category?.responseData?.children ?? [];
//template
const categoryMenu = useMemo(
() => buildDynamicCategoryMenu(category, allCategories),
[category, allCategories],
);
const totalPages = postsQuery.data?.totalPages ?? 1;
const currentPage = Math.min(page, totalPages);
const paginatedPosts = postsQuery.data?.rows ?? [];
return (
<div className="min-h-screen container mx-auto">
{articlesLoading ? (
{postsQuery.isLoading ? (
<div className="flex justify-center items-center w-full h-64">
<Spinner />
</div>
) : (
<div className="w-full flex flex-col gap-5">
{children.length !== 0 ? (
<ListCategory categories={children} />
) : (
<br />
)}
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<main className="lg:col-span-2 bg-background">
<div className="pb-5 overflow-hidden">
{articles?.responseData?.rows.map((item) => (
<CardNews
key={item.id}
news={item}
link={`${item.external_link}`}
/>
))}
{paginatedPosts.length ? (
paginatedPosts.map((item) => {
const fallbackDescription = item.content_structure?.post_content
?.map((section) => section.content)
.join(" ");
return (
<CardNews
key={item.id}
news={{
id: item.id,
title: item.title,
thumbnail:
item.thumbnail?.path ??
item.thumbnail?.original ??
item.thumbnail?.url ??
"",
external_link: item.external_link,
description:
item.summary ||
stripHtml(item.content) ||
stripHtml(fallbackDescription),
release_at:
item.release_at ?? item.published_at ?? item.created_at ?? "",
is_active: item.is_active,
created_at: item.created_at ?? "",
created_by: null,
updated_at: item.created_at ?? "",
updated_by: null,
mode: "NOW",
category: category.name,
page_config: {
id: category.id,
name: category.name,
static_link: category.url,
static_link_en: category.url,
code: category.slug,
},
}}
link={item.external_link}
/>
);
})
) : (
<div className="rounded-lg border 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."}
</div>
)}
<div className="w-full flex justify-center mt-4">
<Pagination
pageCount={Number(articles?.responseData?.totalPages ?? 1)}
page={Number(articles?.responseData?.currentPage ?? page)}
pageCount={totalPages}
page={currentPage}
onChangePage={setPage}
onGoToPreviousPage={() => setPage(Math.max(1, page - 1))}
onGoToNextPage={() =>
setPage(Math.min(Number(articles?.responseData?.totalPages ?? 1), page + 1))
}
onGoToPreviousPage={() => setPage(Math.max(1, currentPage - 1))}
onGoToNextPage={() => setPage(Math.min(totalPages, currentPage + 1))}
/>
</div>
</div>
</main>
<aside className="space-y-6">
<ListFilter onSearch={setSubmitSearch} />
<ListFilter onSearch={setSubmitSearch} onReset={() => setSubmitSearch("")} />
<EventCalendar />
<div className="bg-white border rounded-md overflow-hidden">
<div className="w-full relative bg-gray-100">
<img src="/banner.webp" alt="Quảng cáo" className="object-cover" />
<img src="/banner.webp" alt={"Qu\u1ea3ng c\u00e1o"} className="object-cover" />
</div>
</div>
</aside>
......@@ -103,4 +170,4 @@ export default function ArticlePage() {
)}
</div>
);
}
\ No newline at end of file
}
......@@ -23,6 +23,7 @@ export default function EventPage() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParamsString = searchParams.toString();
// states
const initialPage = Number(searchParams.get("page") ?? "1");
......@@ -31,15 +32,20 @@ export default function EventPage() {
const pageSize = 5;
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
const params = new URLSearchParams(searchParamsString);
if (page > 1) {
params.set("page", String(page));
} else {
params.delete("page");
}
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [page]);
const nextUrl = qs ? `${pathname}?${qs}` : pathname;
const currentUrl = searchParamsString ? `${pathname}?${searchParamsString}` : pathname;
if (nextUrl !== currentUrl) {
router.replace(nextUrl, { scroll: false });
}
}, [page, pathname, router, searchParamsString]);
// query
const { data: categoriesPage } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
......@@ -96,4 +102,4 @@ export default function EventPage() {
</div>
</>
);
}
\ No newline at end of file
}
'use client';
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config";
import ListCategory from "@/components/base/list-category";
import { useParams } from "next/dist/client/components/navigation";
import { Spinner } from "@/components/ui/spinner";
import { GetNewsResponseType } from "@/api/types/news";
import { useGetNews } from "@/api/endpoints/news";
import parse from "html-react-parser";
import ListCategory from "@/components/base/list-category";
import {
buildDynamicCategoryMenu,
getDynamicPostBodyHtml,
} from "./data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
export default function InformationPage() {
// get url
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/");
// query
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
static_link: `/${slug[0]}`,
});
type InformationPageProps = {
post: DynamicPostItem;
category: DynamicCategoryRouteItem;
allCategories: DynamicCategoryRouteItem[];
};
const { data: information, isLoading: informationLoading } = useGetNews<GetNewsResponseType>({
filters: `page_config.static_link==/${path}`,
});
export default function InformationPage({
post,
category,
allCategories,
}: InformationPageProps) {
const categoryMenu = buildDynamicCategoryMenu(category, allCategories);
const children = category?.responseData?.children ?? [];
//template
return (
<div className='container w-full flex justify-center items-center pb-10'>
{informationLoading ? (
<div className="flex justify-center items-center w-full h-64">
<Spinner />
</div>
) : (
<div className='flex flex-col gap-5 w-full'>
{children.length !== 0 ? (
<ListCategory categories={children} />
) : (
<br />
)}
<main className=" bg-white border rounded-md py-10 px-5 md:px-20 lg:px-20">
<div className='text-primary text-2xl leading-normal font-bold'>
{information?.responseData?.rows[0]?.title}
</div>
{/* <div className='flex items-center gap-2 text-sm mb-4'>
<span className='text-base text-blue-700'>
{dayjs(information?.responseData?.rows[0].created_at).format('DD/MM/YYYY')}
</span>
</div> */}
<hr className="my-5" />
<div className='flex-1 text-app-grey text-base overflow-hidden'>
<div className="prose tiptap overflow-hidden">
{parse(information?.responseData?.rows[0]?.description ?? '')}
</div>
<div className="container w-full flex justify-center items-center pb-10">
<div className="flex flex-col gap-5 w-full">
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<main className="bg-white border rounded-md py-10 px-5 md:px-20 lg:px-20">
<div className="text-primary text-2xl leading-normal font-bold">
{post.title}
</div>
<hr className="my-5" />
<div className="flex-1 text-app-grey text-base overflow-hidden">
<div className="prose tiptap max-w-none overflow-hidden">
{parse(getDynamicPostBodyHtml(post))}
</div>
</main>
</div>
)}
</div>
</main>
</div>
</div>
);
}
\ No newline at end of file
}
import type { Category } from "@/api/models/category";
import { useCustomClient } from "@/api/mutator/custom-client";
import Links from "@/links";
import { getCategoryFallbackResponse } from "@/mockdata/categories";
import type {
DynamicCategoryMenuItem,
DynamicCategoryRouteItem,
DynamicCategoryType,
DynamicPostContentSection,
DynamicPostItem,
DynamicPostThumbnail,
} from "./types";
type CategoryListResponse = {
responseData?: {
rows?: Category[];
};
};
type RawPostCategory = {
id?: string | null;
name?: string | null;
url?: string | null;
type?: string | null;
};
type RawPostThumbnail = {
path?: string | null;
original?: string | null;
url?: string | null;
};
type RawPostItem = {
id?: string | null;
title?: string | null;
slug?: string | null;
external_link?: string | null;
content?: string | null;
summary?: string | null;
release_at?: string | null;
published_at?: string | null;
created_at?: string | null;
started_at?: string | null;
ended_at?: string | null;
expired_at?: string | null;
registration_deadline?: string | null;
is_featured?: boolean | null;
is_hidden?: boolean | null;
is_active?: boolean | null;
status?: string | null;
type?: string | null;
thumbnail?: RawPostThumbnail | null;
categories?: RawPostCategory[] | null;
content_structure?: {
post_content?: Array<{
id?: string | null;
type?: string | null;
content?: string | null;
position?: number | null;
}> | null;
} | null;
};
type PostListResponse = {
responseData?: {
count?: number;
page?: number;
pageSize?: number;
rows?: RawPostItem[];
};
};
export type DynamicPostListResult = {
count: number;
page: number;
pageSize: number;
totalPages: number;
rows: DynamicPostItem[];
};
const normalizePath = (value?: string | null) => {
const trimmed = value?.trim() ?? "";
if (!trimmed || trimmed === "/") return "/";
return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
};
const normalizeCategoryType = (value?: string | null): DynamicCategoryType | null => {
if (value === "category" || value === "page" || value === "news") return value;
return null;
};
const sortCategories = (items: DynamicCategoryRouteItem[]) =>
[...items].sort((left, right) => {
const leftOrder = left.sort_order ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.sort_order ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return left.name.localeCompare(right.name, "vi");
});
const mapPostContentSections = (item: RawPostItem): DynamicPostContentSection[] => {
const sections = Array.isArray(item.content_structure?.post_content)
? item.content_structure.post_content
: [];
return sections.map((section, index) => ({
id: String(section?.id ?? `section-${index + 1}`),
type: String(section?.type ?? "text"),
content: String(section?.content ?? ""),
position:
typeof section?.position === "number"
? section.position
: index + 1,
}));
};
const mapPost = (item: RawPostItem): DynamicPostItem => ({
id: String(item.id ?? ""),
title: String(item.title ?? "").trim(),
slug: String(item.slug ?? "").trim(),
external_link: normalizePath(item.external_link),
content: String(item.content ?? ""),
summary: String(item.summary ?? ""),
release_at: item.release_at ?? null,
published_at: item.published_at ?? null,
created_at: item.created_at ?? null,
started_at: item.started_at ?? null,
ended_at: item.ended_at ?? null,
expired_at: item.expired_at ?? null,
registration_deadline: item.registration_deadline ?? null,
is_featured: Boolean(item.is_featured),
is_hidden: Boolean(item.is_hidden),
is_active: item.is_active !== false,
status: String(item.status ?? ""),
type: String(item.type ?? ""),
thumbnail: (item.thumbnail ?? null) as DynamicPostThumbnail,
categories: (item.categories ?? [])
.filter((category) => category?.id && category?.name)
.map((category) => ({
id: String(category.id),
name: String(category.name),
url: normalizePath(category.url),
type: String(category.type ?? ""),
})),
content_structure: {
post_content: mapPostContentSections(item),
},
});
const buildPostFilters = (filters: Array<string | null | undefined>) =>
filters
.map((item) => item?.trim())
.filter(Boolean)
.join(",");
export async function fetchDynamicCategories(): Promise<DynamicCategoryRouteItem[]> {
const response = await useCustomClient<CategoryListResponse>(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC",
).catch(() => getCategoryFallbackResponse());
const rows = response.responseData?.rows ?? [];
return sortCategories(
rows
.map((item) => {
const type = normalizeCategoryType(item.type);
if (!item.id || !item.name || !type) return null;
return {
id: item.id,
name: item.name,
slug: item.slug ?? "",
url: normalizePath(item.url),
type,
parent_id: item.parent_id ?? null,
sort_order: item.sort_order ?? null,
} satisfies DynamicCategoryRouteItem;
})
.filter((item): item is DynamicCategoryRouteItem => Boolean(item)),
);
}
export async function fetchDynamicPostList(params: {
filters?: string;
page?: number;
pageSize?: number;
sortField?: string;
sortOrder?: string;
}): Promise<DynamicPostListResult> {
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 5;
const query = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
sortField: params.sortField ?? "release_at",
sortOrder: params.sortOrder ?? "desc",
});
if (params.filters?.trim()) {
query.set("filters", params.filters.trim());
}
const response = await useCustomClient<PostListResponse>(`/post?${query.toString()}`);
const count = Number(response.responseData?.count ?? 0);
return {
count,
page,
pageSize,
totalPages: pageSize > 0 ? Math.max(1, Math.ceil(count / pageSize)) : 1,
rows: (response.responseData?.rows ?? []).map(mapPost).filter((item) => item.id && item.title),
};
}
export async function fetchDynamicPostByExternalLink(path: string) {
const result = await fetchDynamicPostList({
page: 1,
pageSize: 1,
filters: buildPostFilters([
`external_link==${normalizePath(path)}`,
"is_hidden==false",
"is_active==true",
"status==published",
]),
});
return result.rows[0] ?? null;
}
export async function fetchDynamicSinglePagePost(categoryId: string) {
const result = await fetchDynamicPostList({
page: 1,
pageSize: 1,
filters: buildPostFilters([
`category.id==${categoryId}`,
"is_hidden==false",
"is_active==true",
"type==page",
]),
});
return result.rows[0] ?? null;
}
export function findDynamicCategoryByPath(
categories: DynamicCategoryRouteItem[],
path: string,
) {
const normalizedPath = normalizePath(path);
return categories.find((item) => normalizePath(item.url) === normalizedPath) ?? null;
}
export function findMenuCategoryForPost(
post: DynamicPostItem | null,
categories: DynamicCategoryRouteItem[],
) {
if (!post) return null;
for (const category of post.categories) {
const matched = categories.find((item) => item.id === category.id);
if (matched) return matched;
}
return null;
}
export function buildDynamicCategoryMenu(
activeCategory: DynamicCategoryRouteItem | null,
categories: DynamicCategoryRouteItem[],
): DynamicCategoryMenuItem[] {
if (!activeCategory) return [];
const relatedItems = activeCategory.parent_id
? categories.filter((item) => item.parent_id === activeCategory.parent_id)
: categories.filter((item) => item.parent_id === activeCategory.id);
return sortCategories(relatedItems).map((item) => ({
id: item.id,
name: item.name,
static_link: item.url,
}));
}
export function findFirstChildCategory(
category: DynamicCategoryRouteItem,
categories: DynamicCategoryRouteItem[],
) {
return sortCategories(categories.filter((item) => item.parent_id === category.id))[0] ?? null;
}
export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) {
const value = thumbnail?.path ?? thumbnail?.original ?? thumbnail?.url ?? "";
if (!value) return "/thumbnail.png";
if (value.startsWith("http://") || value.startsWith("https://")) return value;
if (value.startsWith("/")) return `${Links.imageEndpoint.replace(/\/+$/, "")}${value}`;
return `${Links.imageEndpoint}${value.replace(/^\/+/, "")}`;
}
export function stripHtml(value?: string | null) {
if (!value) return "";
return value
.replace(/<img[^>]*>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function getDynamicPostBodyHtml(post: DynamicPostItem | null) {
if (!post) return "";
const primaryContent = post.content?.trim();
if (primaryContent) return primaryContent;
const structuredContent = (post.content_structure?.post_content ?? [])
.sort((left, right) => left.position - right.position)
.map((section) => section.content?.trim() ?? "")
.filter(Boolean)
.join("\n");
return structuredContent || post.summary?.trim() || "";
}
export function matchesDynamicPostCategory(post: DynamicPostItem, categoryId: string) {
return post.categories.some((category) => category.id === categoryId);
}
export function isDynamicPostVisible(post: DynamicPostItem) {
if (post.is_hidden) return false;
if (!post.is_active) return false;
if (post.status && post.status !== "published") return false;
return true;
}
export { buildPostFilters, normalizePath };
export type DynamicCategoryType = "category" | "page" | "news";
export type DynamicCategoryRouteItem = {
id: string;
name: string;
slug: string;
url: string;
type: DynamicCategoryType;
parent_id: string | null;
sort_order: number | null;
};
export type DynamicCategoryMenuItem = {
id: string;
name: string;
static_link: string;
};
export type DynamicPostCategoryItem = {
id: string;
name: string;
url: string;
type: string;
};
export type DynamicPostThumbnail = {
path?: string | null;
original?: string | null;
url?: string | null;
} | null;
export type DynamicPostContentSection = {
id: string;
type: string;
content: string;
position: number;
};
export type DynamicPostItem = {
id: string;
title: string;
slug: string;
external_link: string;
content: string;
summary: string;
release_at: string | null;
published_at: string | null;
created_at: string | null;
started_at: string | null;
ended_at: string | null;
expired_at: string | null;
registration_deadline: string | null;
is_featured: boolean;
is_hidden: boolean;
is_active: boolean;
status: string;
type: string;
thumbnail: DynamicPostThumbnail;
categories: DynamicPostCategoryItem[];
content_structure: {
post_content: DynamicPostContentSection[];
} | null;
};
......@@ -432,7 +432,11 @@ export default function AdminBaseConfigPage() {
saveConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
toast.success(itemDialogMode === "logo" ? "Đã lưu cấu hình logo" : "Đã lưu cấu hình banner");
toast.success(
itemDialogMode === "logo"
? "Đã lưu cấu hình logo"
: "Đã lưu cấu hình banner",
);
};
const handleDeleteItem = () => {
......@@ -575,7 +579,7 @@ export default function AdminBaseConfigPage() {
value="social"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Mạng xã hội
M?ng x? h?i
</TabsTrigger>
</TabsList>
......@@ -700,7 +704,7 @@ export default function AdminBaseConfigPage() {
</div>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-8 text-center text-sm text-gray-500">
Chưa có logo nào. Hãy thiết lập logo cho website.
Chua c? logo n?o. H?y thi?t l?p logo cho website.
</div>
)}
</div>
......@@ -974,7 +978,7 @@ export default function AdminBaseConfigPage() {
</>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-10 text-center text-sm text-gray-500">
Chưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu hình.
Chua c? chi nh?nh n?o. H?y th?m chi nh?nh d? b?t d?u c?u h?nh.
</div>
)}
</div>
......@@ -1018,9 +1022,9 @@ export default function AdminBaseConfigPage() {
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Mạng xã hội</CardTitle>
<CardTitle className="text-2xl text-[#163b73]">M?ng x? h?i</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý link mạng xã hội và thứ tự hiển thị trên website.
Qu?n l? link m?ng x? h?i v? th? t? hi?n th? tr?n website.
</CardDescription>
</div>
......@@ -1030,7 +1034,7 @@ export default function AdminBaseConfigPage() {
className="rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
Lưu mạng xã hội
Luu m?ng x? h?i
</Button>
</div>
</CardHeader>
......
......@@ -2,13 +2,13 @@
import * as React from "react";
import dayjs from "dayjs";
import { Eye, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
......@@ -177,26 +177,12 @@ export default function AdminContactRequestsPage() {
{formatDateTime(item.submittedAt)}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDetailTarget(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "view", label: "Xem chi tiết đơn", onClick: () => setDetailTarget(item) },
{ kind: "delete", label: "Xóa đơn liên hệ", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
......
......@@ -2,13 +2,13 @@
import * as React from "react";
import dayjs from "dayjs";
import { Eye, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
......@@ -136,26 +136,16 @@ export default function AdminMembershipApplicationsPage() {
{formatDateTime(item.submittedAt)}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDetailTarget(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "view", label: "Xem chi tiết đơn", onClick: () => setDetailTarget(item) },
{
kind: "delete",
label: "Xóa đơn đăng ký hội viên",
onClick: () => setDeleteTarget(item),
},
]}
/>
</TableCell>
</TableRow>
))
......
......@@ -2,12 +2,12 @@
import * as React from "react";
import dayjs from "dayjs";
import { Eye, Mail, Trash2 } from "lucide-react";
import { Mail } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
......@@ -116,28 +116,14 @@ export default function AdminNewsletterEmailsPage() {
<TableCell className="py-3 text-center text-sm text-gray-700">
{formatDateTime(item.submittedAt)}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDetailTarget(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
<TableCell className="py-3 text-center">
<AdminRowActions
actions={[
{ kind: "view", label: "Xem chi tiết email", onClick: () => setDetailTarget(item) },
{ kind: "delete", label: "Xóa email đăng ký", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
)}
......
......@@ -159,7 +159,7 @@ export default function AdminDashboardPage() {
() => [
{
title: "Cấu hình chung",
description: "Logo, banner, chi nhánh liên hệ và mạng xã hội",
description: "Logo, banner, chi nh?nh li?n h? v? m?ng x? h?i",
href: "/admin/base-config",
icon: Globe,
},
......@@ -313,7 +313,7 @@ export default function AdminDashboardPage() {
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{activeBanners.length}</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Mạng xã hội hiển thị</div>
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">M?ng x? h?i hi?n th?</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{visibleSocials.length}</div>
</div>
</div>
......
"use client";
"use client";
import React from "react";
import dayjs from "dayjs";
......@@ -7,27 +7,18 @@ import { useParams, useRouter } from "next/navigation";
import { toast } from "sonner";
import {
ArrowLeft,
Edit,
EyeOff,
FileText,
MoreHorizontal,
Plus,
Star,
Trash2,
} from "lucide-react";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
......@@ -358,37 +349,22 @@ export default function HeaderCategoryPostsPage() {
)}
</TableCell>
<TableCell className="text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
asChild
className="text-gray-700 focus:text-[#063e8e]"
>
<Link href={`/admin/header-config/${categoryId}/posts/${item.id}`}>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
<TableCell className="text-center">
<AdminRowActions
actions={[
{
kind: "edit",
label: "Chỉnh sửa bài viết",
onClick: () => router.push(`/admin/header-config/${categoryId}/posts/${item.id}`),
},
{
kind: "delete",
label: "Xóa bài viết",
onClick: () => setDeleteTarget(item),
},
]}
/>
</TableCell>
</TableRow>
))
)}
......
......@@ -5,24 +5,14 @@ import Link from "next/link";
import {
ChevronDown,
ChevronRight,
Edit,
ExternalLink,
FileText,
FolderTree,
MoreHorizontal,
Plus,
Trash,
} from "lucide-react";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
......@@ -42,6 +32,8 @@ export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
parentId: string | null;
};
const PROTECTED_HOME_CATEGORY_ID = "root-home";
interface HeaderCategoryTableProps {
rows: HeaderCategoryFlatRow[];
expanded: Record<string, boolean>;
......@@ -160,8 +152,11 @@ export function HeaderCategoryTable({
rows.map((item, index) => {
const hasChildren = item.children.length > 0;
const isExpanded = expanded[item.id] ?? true;
const canCreateChild = !item.parent_id && item.type === "category";
const canManagePosts = item.type === "page" || item.type === "news";
const isProtectedHomeCategory = item.id === PROTECTED_HOME_CATEGORY_ID;
const canCreateChild =
!isProtectedHomeCategory && !item.parent_id && item.type === "category";
const canManagePosts =
!isProtectedHomeCategory && (item.type === "page" || item.type === "news");
return (
<TableRow
......@@ -221,56 +216,46 @@ export function HeaderCategoryTable({
</TableCell>
<TableCell className="w-[120px] text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onEdit(item)}
>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</DropdownMenuItem>
{canManagePosts ? (
<DropdownMenuItem
asChild
className="text-gray-700 focus:text-[#063e8e]"
>
<Link href={`/admin/header-config/${item.id}/posts`}>
<FileText className="mr-2 h-4 w-4" />
Quản lý bài viết
</Link>
</DropdownMenuItem>
) : null}
{canCreateChild ? (
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onCreateChild(item)}
>
<Plus className="mr-2 h-4 w-4" />
Thêm danh mục con
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onDelete(item)}
>
<Trash className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AdminRowActions
actions={[
...(!isProtectedHomeCategory
? [
{
kind: "edit" as const,
label: "Chỉnh sửa danh mục",
onClick: () => onEdit(item),
},
]
: []),
...(canManagePosts
? [
{
kind: "manage" as const,
label: "Quản lý bài viết",
href: `/admin/header-config/${item.id}/posts`,
},
]
: []),
...(canCreateChild
? [
{
kind: "create-child" as const,
label: "Thêm danh mục con",
onClick: () => onCreateChild(item),
},
]
: []),
...(!isProtectedHomeCategory
? [
{
kind: "delete" as const,
label: "Xóa danh mục",
onClick: () => onDelete(item),
},
]
: []),
]}
/>
</TableCell>
</TableRow>
);
......
......@@ -34,6 +34,12 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
description: "",
};
const PROTECTED_HOME_CATEGORY_ID = "root-home";
function isProtectedHomeCategory(itemId?: string | null) {
return itemId === PROTECTED_HOME_CATEGORY_ID;
}
function toFormValues(item?: CmsHeaderCategoryItem | null): HeaderCategoryFormValues {
if (!item) return EMPTY_HEADER_CATEGORY_FORM;
......@@ -199,6 +205,8 @@ export default function HeaderConfigPage() {
};
const openEdit = (item: HeaderCategoryTreeItem) => {
if (isProtectedHomeCategory(item.id)) return;
const fullItem = itemMap.get(item.id) ?? null;
setFormMode("edit");
setFormValues(toFormValues(fullItem));
......@@ -223,6 +231,10 @@ export default function HeaderConfigPage() {
const handleSubmit = async () => {
if (isSubmitting) return;
if (isProtectedHomeCategory(formValues.id)) {
return;
}
if (!formValues.name.trim()) {
toast.error("Tên danh mục là bắt buộc");
return;
......@@ -293,6 +305,11 @@ export default function HeaderConfigPage() {
const handleDelete = async () => {
if (!deleteTarget || isSubmitting) return;
if (isProtectedHomeCategory(deleteTarget.id)) {
setDeleteTarget(null);
return;
}
setIsSubmitting(true);
try {
......
......@@ -14,33 +14,17 @@ import {
ShieldCheck,
} from "lucide-react";
import { toast } from "sonner";
import { postAuthLogin } from "@/api/endpoints/auth";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import logo from "@/assets/VCCI-HCM-logo-VN-2025.png";
import { loginAdmin } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore";
type AuthMode = "login" | "forgot" | "reset";
type ResetStep = "request" | "verify" | "password" | "done";
type LoginApiSuccess = {
responseData?: LoginPayload;
data?: {
responseData?: LoginPayload;
};
};
type LoginPayload = {
access_token?: string;
refresh_token?: string;
expires_in?: number;
user?: {
email?: string;
};
};
type ApiEnvelope<T = unknown> = {
responseData?: T;
data?: {
......@@ -141,8 +125,8 @@ function AuthShell({
mode === "login"
? "Truy cập khu vực quản trị nội dung VCCI News."
: mode === "forgot"
? "Xác thực email quản trị để nhận mã OTP."
: "Nhập mã OTP và tạo mật khẩu mới cho tài khoản.";
? "X?c th?c email qu?n tr? d? nh?n m? OTP."
: "Nh?p m? OTP v? t?o m?t kh?u m?i cho t?i kho?n.";
return (
<div className="min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700">
......@@ -290,7 +274,6 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const rememberState = useAuthStore((state) => state.appUserRemember);
const setAppToken = useAuthStore((state) => state.setAppToken);
const setAppUserRemember = useAuthStore((state) => state.setAppUserRemember);
const [mode, setMode] = useState<AuthMode>("login");
......@@ -329,18 +312,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setLoginLoading(true);
try {
const response = await postAuthLogin({
email: email.trim(),
password,
});
const payload = getResponseData<LoginPayload>(response as unknown as LoginApiSuccess);
if (!payload?.access_token || !payload.expires_in) {
throw new Error("Thiếu dữ liệu token từ API đăng nhập.");
}
setAppToken(payload.access_token, payload.expires_in, payload.refresh_token);
await loginAdmin(email.trim(), password);
setAppUserRemember(remember ? email.trim() : "", remember ? password : "", remember);
toast.success("Đăng nhập quản trị thành công");
......@@ -365,9 +337,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetStep("verify");
setMode("reset");
setResetMessage("Mã OTP đã được gửi đến email quản trị.");
setResetMessage("M? OTP d? du?c g?i d?n email qu?n tr?.");
} catch (error) {
setResetError(getAuthErrorMessage(error, "Không thể gửi mã OTP. Vui lòng thử lại."));
setResetError(getAuthErrorMessage(error, "Kh?ng th? g?i m? OTP. Vui l?ng th? l?i."));
} finally {
setResetLoading(false);
}
......@@ -390,14 +362,14 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const payload = getResponseData<VerifyOtpPayload>(response);
if (!payload?.reset_token) {
throw new Error("Không nhận được mã đặt lại mật khẩu từ API.");
throw new Error("Kh?ng nh?n du?c m? d?t l?i m?t kh?u t? API.");
}
setResetToken(payload.reset_token);
setResetStep("password");
setResetMessage("OTP hợp lệ. Bạn có thể tạo mật khẩu mới.");
} catch (error) {
setResetError(getAuthErrorMessage(error, "OTP không hợp lệ hoặc đã hết hạn."));
setResetError(getAuthErrorMessage(error, "OTP kh?ng h?p l? ho?c d? h?t h?n."));
} finally {
setResetLoading(false);
}
......@@ -571,7 +543,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
Đang gửi OTP...
</>
) : (
"Gửi mã OTP"
"G?i m? OTP"
)}
</Button>
......@@ -621,7 +593,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<form className="space-y-5" onSubmit={handleVerifyOtp}>
<div className="space-y-2">
<Label htmlFor="otp" className="text-gray-700">
Mã OTP
M? OTP
</Label>
<Input
id="otp"
......@@ -650,7 +622,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
onClick={switchToForgot}
className="h-10 w-full rounded-xl text-gray-700 hover:bg-[#edf4ff] hover:text-[#063e8e]"
>
Gửi lại mã OTP
G?i l?i m? OTP
</Button>
</form>
) : null}
......@@ -700,7 +672,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<div className="space-y-5">
<div className="rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-[#063e8e]" />
<div className="mt-3 text-base font-semibold text-gray-900">Mật khẩu đã được cập nhật</div>
<div className="mt-3 text-base font-semibold text-gray-900">M?t kh?u d? du?c c?p nh?t</div>
<p className="mt-2 text-sm leading-6 text-gray-700">
Quay lại màn đăng nhập để vào khu vực quản trị bằng mật khẩu mới.
</p>
......
......@@ -429,7 +429,7 @@ export default function AdminMediaPage() {
</div>
<h2 className="mt-5 text-lg font-semibold text-slate-800">Chưa có ảnh phù hợp</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
Hãy tải ảnh mới hoặc thử lại với từ khóa khác để tìm đúng hình ảnh bạn cần.
H?y t?i ?nh m?i ho?c th? l?i v?i t? kh?a kh?c d? t?m d?ng h?nh ?nh b?n c?n.
</p>
</div>
) : (
......
"use client";
import * as React from "react";
import { Edit, Plus, Save, Trash2, X } from "lucide-react";
import { Plus, Save, X } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
......@@ -206,26 +207,12 @@ export default function AdminMemberFieldsPage() {
{item.name}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => openEdit(item)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "edit", label: "Chỉnh sửa lĩnh vực", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa lĩnh vực", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
......
"use client";
import * as React from "react";
import { Edit, MoreHorizontal, Plus, Trash2, Users } from "lucide-react";
import { Plus, Users } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
......@@ -258,34 +252,20 @@ export default function AdminMembersPage() {
<span className="line-clamp-2">{item.address || "—"}</span>
</TableCell>
<TableCell className="px-4 py-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-[#063e8e]/15">
<DropdownMenuItem
className="cursor-pointer text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"
onClick={() => router.push(`/admin/members/${item.id}`)}
>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:bg-red-50 focus:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AdminRowActions
actions={[
{
kind: "edit",
label: "Chỉnh sửa hội viên",
onClick: () => router.push(`/admin/members/${item.id}`),
},
{
kind: "delete",
label: "Xóa hội viên",
onClick: () => setDeleteTarget(item),
},
]}
/>
</TableCell>
</TableRow>
))
......
"use client";
import * as React from "react";
import { Edit, Plus, Save, Trash2, X } from "lucide-react";
import { Plus, Save, X } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
......@@ -206,26 +207,12 @@ export default function AdminMemberRegionsPage() {
{item.name}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => openEdit(item)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "edit", label: "Chỉnh sửa khu vực", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa khu vực", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
......
......@@ -3,30 +3,31 @@
import * as React from "react";
import dayjs from "dayjs";
import {
Edit,
Check,
ChevronsUpDown,
EyeOff,
MoreHorizontal,
Plus,
Star,
Tag,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
......@@ -53,7 +54,12 @@ import {
ADMIN_NEWS_TYPE_OPTIONS,
type AdminNewsItem,
} from "@/mockdata/admin-news";
import { type HeaderCategoryItem } from "@/mockdata/header-config";
import {
buildHeaderCategoryTree,
type HeaderCategoryItem,
type HeaderCategoryTreeItem,
} from "@/mockdata/header-config";
import { cn } from "@/lib/utils";
const selectTriggerClassName =
"w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[180px]";
......@@ -63,12 +69,126 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function flattenHeaderTree(
items: HeaderCategoryTreeItem[],
depth = 0,
): Array<HeaderCategoryItem & { depth: number }> {
return items.flatMap((item) => [
{ ...item, depth },
...flattenHeaderTree(item.children, depth + 1),
]);
}
function formatHeaderCategoryOptionLabel(option: { name: string; depth: number }) {
return `${"-- ".repeat(option.depth)}${option.name}`;
}
function CategoryFilterCombobox({
value,
options,
onChange,
}: {
value: string;
options: Array<HeaderCategoryItem & { depth: number }>;
onChange: (value: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const selectedOption = options.find((option) => option.id === value) ?? null;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"h-10 w-full justify-between rounded-xl border-[#063e8e]/15 bg-white px-3 font-normal text-gray-700 hover:bg-white hover:text-gray-700 focus-visible:ring-[#063e8e]/30 lg:w-[220px]",
!selectedOption && "text-gray-700",
)}
>
<span className="truncate text-left">
{selectedOption
? formatHeaderCategoryOptionLabel(selectedOption)
: "T\u1ea5t c\u1ea3 danh m\u1ee5c"}
</span>
<ChevronsUpDown className="ml-3 h-4 w-4 shrink-0 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[var(--radix-popover-trigger-width)] border-[#063e8e]/15 bg-white p-0 text-gray-700"
>
<Command className="bg-white text-gray-700">
<CommandInput
placeholder={"T\u00ecm danh m\u1ee5c hi\u1ec3n th\u1ecb"}
className="text-gray-700 placeholder:text-gray-500"
/>
<CommandList className="max-h-72">
<CommandEmpty className="text-gray-700">
{"Kh\u00f4ng t\u00ecm th\u1ea5y danh m\u1ee5c ph\u00f9 h\u1ee3p"}
</CommandEmpty>
<CommandItem
value="all Tat ca danh muc"
onSelect={() => {
onChange("all");
setOpen(false);
}}
className="gap-3 px-3 py-2 text-gray-700 data-[selected=true]:bg-[#063e8e]/10 data-[selected=true]:text-[#063e8e]"
>
<Check
className={cn(
"h-4 w-4 text-[#063e8e]",
value === "all" ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{"T\u1ea5t c\u1ea3 danh m\u1ee5c"}</span>
</CommandItem>
{options.map((option) => (
<CommandItem
key={option.id}
value={`${option.id} ${option.name} ${option.type}`}
onSelect={() => {
onChange(option.id);
setOpen(false);
}}
className="gap-3 px-3 py-2 text-gray-700 data-[selected=true]:bg-[#063e8e]/10 data-[selected=true]:text-[#063e8e]"
>
<Check
className={cn(
"h-4 w-4 text-[#063e8e]",
value === option.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">
{formatHeaderCategoryOptionLabel(option)}
</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function formatDateTime(value: string) {
return value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "—";
}
function stripHtml(html: string) {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
function useDebouncedValue<T>(value: T, delay = 350) {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const timeout = window.setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => window.clearTimeout(timeout);
}, [delay, value]);
return debouncedValue;
}
function AdminNewsTableLoading() {
......@@ -98,10 +218,40 @@ export default function AdminNewsPage() {
const [page, setPage] = React.useState(1);
const [pageSize] = React.useState(20);
const [total, setTotal] = React.useState(0);
const debouncedSearch = useDebouncedValue(search);
const apiFilters = React.useMemo(() => {
const filters: string[] = [];
const keyword = debouncedSearch.trim();
if (keyword) {
filters.push(`title@=${keyword}`);
}
if (categoryFilter !== "all") {
filters.push(`category.id==${categoryFilter}`);
}
if (statusFilter === "visible") {
filters.push("is_hidden==false");
} else if (statusFilter === "hidden") {
filters.push("is_hidden==true");
}
return filters.join(",");
}, [categoryFilter, debouncedSearch, statusFilter]);
const load = React.useCallback(async () => {
setReady(false);
const [newsData, headerConfig] = await Promise.all([
fetchCmsNewsItems({ page, pageSize, sortField: "created_at", sortOrder: "desc" }),
fetchCmsNewsItems({
page,
pageSize,
sortField: "created_at",
sortOrder: "desc",
filters: apiFilters,
}),
fetchHeaderConfigItems(),
]);
......@@ -109,58 +259,36 @@ export default function AdminNewsPage() {
setTotal(newsData.total);
setHeaderItems(headerConfig.items);
setReady(true);
}, [page, pageSize]);
}, [apiFilters, page, pageSize]);
React.useEffect(() => {
void load().catch((error) => {
toast.error(error instanceof Error ? error.message : "Không thể tải danh sách bài viết");
toast.error(
error instanceof Error
? error.message
: "Không thể tải danh sách bài viết",
);
setReady(true);
});
}, [load]);
React.useEffect(() => {
setPage((currentPage) => (currentPage === 1 ? currentPage : 1));
}, [apiFilters, typeFilter]);
const categoryOptions = React.useMemo(() => {
return headerItems.filter((item) => item.type === "news" || item.type === "page");
return flattenHeaderTree(buildHeaderCategoryTree(headerItems)).filter(
(item) => item.type === "news" || item.type === "page",
);
}, [headerItems]);
const filteredItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
return items
.filter((item) => {
const categoryName =
headerItems.find((category) => category.id === item.header_category_id)?.name ?? "";
const matchesKeyword =
!keyword ||
item.title.toLowerCase().includes(keyword) ||
item.slug.toLowerCase().includes(keyword) ||
stripHtml(item.summary).toLowerCase().includes(keyword) ||
categoryName.toLowerCase().includes(keyword);
const matchesType = typeFilter === "all" || item.type === typeFilter;
const matchesCategory =
categoryFilter === "all" || item.header_category_id === categoryFilter;
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "visible" && !item.is_hidden) ||
(statusFilter === "hidden" && item.is_hidden);
return matchesKeyword && matchesType && matchesCategory && matchesStatus;
})
.sort((left, right) => {
const leftFeatured = left.type === "tintuc" && left.is_featured ? 1 : 0;
const rightFeatured = right.type === "tintuc" && right.is_featured ? 1 : 0;
if (leftFeatured !== rightFeatured) {
return rightFeatured - leftFeatured;
}
const leftTime = new Date(left.published_at || left.created_at).getTime();
const rightTime = new Date(right.published_at || right.created_at).getTime();
if (typeFilter === "all") {
return items;
}
return rightTime - leftTime;
});
}, [categoryFilter, headerItems, items, search, statusFilter, typeFilter]);
return items.filter((item) => item.type === typeFilter);
}, [items, typeFilter]);
const stats = React.useMemo(() => {
return [
......@@ -171,16 +299,16 @@ export default function AdminNewsPage() {
},
{
label: "Đang hiển thị",
value: items.filter((item) => !item.is_hidden).length,
value: filteredItems.filter((item) => !item.is_hidden).length,
icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Tin nổi bật",
value: items.filter((item) => item.type === "tintuc" && item.is_featured).length,
value: filteredItems.filter((item) => item.type === "tintuc" && item.is_featured).length,
icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
},
];
}, [total, items]);
}, [filteredItems, total]);
const handleDelete = async () => {
if (!deleteTarget || isDeleting) return;
......@@ -193,7 +321,9 @@ export default function AdminNewsPage() {
setDeleteTarget(null);
await load();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Không thể xóa bài viết");
toast.error(
error instanceof Error ? error.message : "Không thể xóa bài viết",
);
} finally {
setIsDeleting(false);
}
......@@ -240,25 +370,11 @@ export default function AdminNewsPage() {
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Danh mục hiển thị" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả danh mục
</SelectItem>
{categoryOptions.map((category) => (
<SelectItem
key={category.id}
value={category.id}
className={selectItemClassName}
>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<CategoryFilterCombobox
value={categoryFilter}
options={categoryOptions}
onChange={setCategoryFilter}
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className={selectTriggerClassName}>
......@@ -391,35 +507,20 @@ export default function AdminNewsPage() {
</TableCell>
<TableCell className="text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
asChild
className="text-gray-700 focus:text-[#063e8e]"
>
<Link href={`/admin/news/${item.id}`}>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AdminRowActions
actions={[
{
kind: "edit",
label: "Chỉnh sửa bài viết",
onClick: () => router.push(`/admin/news/${item.id}`),
},
{
kind: "delete",
label: "Xóa bài viết",
onClick: () => setDeleteTarget(item),
},
]}
/>
</TableCell>
</TableRow>
);
......@@ -432,7 +533,8 @@ export default function AdminNewsPage() {
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-[#063e8e]/10 px-4 py-3">
<div className="text-sm text-gray-700">
Hiển thị {(page - 1) * pageSize + 1} đến {Math.min(page * pageSize, total)} của {total} bài viết
Hiển thị {(page - 1) * pageSize + 1} đến{" "}
{Math.min(page * pageSize, total)} của {total} bài viết
</div>
<div className="flex items-center gap-2">
<Button
......
......@@ -2,9 +2,10 @@
import * as React from "react";
import dayjs from "dayjs";
import { Edit2, Hash, Plus, Tag, Trash2 } from "lucide-react";
import { ChevronLeft, ChevronRight, Hash, Plus, Tag } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
......@@ -30,7 +31,7 @@ import {
type CmsTagItem,
createCmsTag,
deleteCmsTag,
fetchCmsTags,
fetchCmsTagsPage,
updateCmsTag,
} from "@/lib/api/cms-admin";
......@@ -68,12 +69,17 @@ export default function AdminTagsPage() {
const [formOpen, setFormOpen] = React.useState(false);
const [formValues, setFormValues] = React.useState<TagFormValues>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = React.useState<CmsTagItem | null>(null);
const [page, setPage] = React.useState(1);
const [pageSize] = React.useState(10);
const [total, setTotal] = React.useState(0);
const load = React.useCallback(async () => {
const nextItems = await fetchCmsTags();
setItems(nextItems);
setIsReady(false);
const result = await fetchCmsTagsPage({ page, pageSize });
setItems(result.items);
setTotal(result.total);
setIsReady(true);
}, []);
}, [page, pageSize]);
React.useEffect(() => {
void load().catch((error) => {
......@@ -93,6 +99,18 @@ export default function AdminTagsPage() {
);
}, [items, search]);
const totalPages = Math.ceil(total / pageSize);
React.useEffect(() => {
setPage((currentPage) => (currentPage === 1 ? currentPage : 1));
}, [search]);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage);
}
};
const openCreate = () => {
setFormValues(EMPTY_FORM);
setFormOpen(true);
......@@ -176,7 +194,7 @@ export default function AdminTagsPage() {
actionDisabled={!isReady}
actionMeta={
<div className="rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]">
Tổng số tags: {items.length}
Tổng số tags: {total}
</div>
}
onSearchChange={setSearch}
......@@ -238,32 +256,77 @@ export default function AdminTagsPage() {
{item.updated_at ? dayjs(item.updated_at).format("DD/MM/YYYY") : "-"}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex justify-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
onClick={() => openEdit(item)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 border-red-100 bg-white text-red-600 hover:bg-red-50"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "edit", label: "Chỉnh sửa tag", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa tag", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-[#063e8e]/10 px-4 py-3">
<div className="text-sm text-gray-700">
Hiển thị {(page - 1) * pageSize + 1} đến{" "}
{Math.min(page * pageSize, total)} của {total} tag
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, index) => {
let pageNum;
if (totalPages <= 5) {
pageNum = index + 1;
} else if (page <= 3) {
pageNum = index + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + index;
} else {
pageNum = page - 2 + index;
}
return (
<Button
key={pageNum}
variant={page === pageNum ? "default" : "outline"}
size="icon"
className={
page === pageNum
? "h-8 w-8 bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
: "h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
}
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</AdminTableLayout>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
......
"use client";
import * as React from "react";
import { Edit, Plus, Save, Trash2, Video, X } from "lucide-react";
import { Plus, Save, Video, X } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
......@@ -273,26 +274,12 @@ export default function AdminVideosPage() {
</Link>
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => openEdit(item)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "edit", label: "Chỉnh sửa video", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa video", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
......
......@@ -33,18 +33,24 @@ export function AdminDeleteDialog({
}: AdminDeleteDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogContent className="rounded-3xl border border-[#063e8e]/15 bg-white p-6 shadow-xl">
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
<AlertDialogTitle className="text-xl font-semibold text-gray-900">
{title}
</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-6 text-gray-700">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction className="bg-[#063e8e] hover:bg-[#063e8e]/90" onClick={onConfirm}>
<AlertDialogFooter className="mt-2 gap-2">
<AlertDialogCancel className="mt-0 border-[#063e8e]/15 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900">
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction className="bg-red-600 text-white hover:bg-red-700" onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
}
\ No newline at end of file
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import {
Eye,
FileText,
FolderPlus,
PencilLine,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type AdminRowActionKind = "edit" | "view" | "delete" | "manage" | "create-child";
type AdminRowActionBase = {
label: string;
disabled?: boolean;
};
type AdminRowAction =
| (AdminRowActionBase & {
kind: "edit";
onClick: () => void;
})
| (AdminRowActionBase & {
kind: "view";
onClick: () => void;
})
| (AdminRowActionBase & {
kind: "delete";
onClick: () => void;
})
| (AdminRowActionBase & {
kind: "manage";
href: string;
})
| (AdminRowActionBase & {
kind: "create-child";
onClick: () => void;
});
interface AdminRowActionsProps {
actions: AdminRowAction[];
className?: string;
}
const actionStyles: Record<
AdminRowActionKind,
{
button: string;
icon: ReactNode;
}
> = {
edit: {
button:
"border-[#063e8e]/15 bg-white text-[#063e8e] hover:border-[#063e8e]/25 hover:bg-[#063e8e]/10 hover:text-[#063e8e]",
icon: <PencilLine className="h-4 w-4" />,
},
view: {
button:
"border-emerald-100 bg-white text-emerald-600 hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
icon: <Eye className="h-4 w-4" />,
},
delete: {
button:
"border-red-100 bg-white text-red-600 hover:border-red-200 hover:bg-red-50 hover:text-red-700",
icon: <Trash2 className="h-4 w-4" />,
},
manage: {
button:
"border-[#063e8e]/15 bg-white text-[#063e8e] hover:border-[#063e8e]/25 hover:bg-[#063e8e]/10 hover:text-[#063e8e]",
icon: <FileText className="h-4 w-4" />,
},
"create-child": {
button:
"border-sky-100 bg-white text-sky-600 hover:border-sky-200 hover:bg-sky-50 hover:text-sky-700",
icon: <FolderPlus className="h-4 w-4" />,
},
};
function AdminRowActionButton({ action }: { action: AdminRowAction }) {
const style = actionStyles[action.kind];
const sharedClassName = cn("h-8 w-8 rounded-lg shadow-sm", style.button);
if (action.kind === "manage") {
return (
<Button
asChild
type="button"
variant="outline"
size="icon"
title={action.label}
aria-label={action.label}
disabled={action.disabled}
className={sharedClassName}
>
<Link href={action.href}>{style.icon}</Link>
</Button>
);
}
return (
<Button
type="button"
variant="outline"
size="icon"
title={action.label}
aria-label={action.label}
disabled={action.disabled}
onClick={action.onClick}
className={sharedClassName}
>
{style.icon}
</Button>
);
}
export function AdminRowActions({ actions, className }: AdminRowActionsProps) {
if (actions.length === 0) return null;
return (
<div className={cn("flex items-center justify-center gap-1.5", className)}>
{actions.map((action) => (
<AdminRowActionButton key={`${action.kind}-${action.label}`} action={action} />
))}
</div>
);
}
......@@ -66,4 +66,4 @@ export function AdminTableLayout({
</div>
</div>
);
}
}
\ No newline at end of file
......@@ -143,7 +143,7 @@ export function AdminImagePicker({
<ImagePlus className="mb-3 h-10 w-10 text-[#063e8e]" />
<p className="text-base font-medium text-black">Chưa có hình ảnh phù hợp</p>
<p className="mt-1 text-sm text-gray-700">
Hãy thử từ khóa khác hoặc tải thêm hình ảnh vào thư viện.
H?y th? t? kh?a kh?c ho?c t?i th?m h?nh ?nh v?o thu vi?n.
</p>
</div>
) : (
......@@ -172,7 +172,7 @@ export function AdminImagePicker({
/>
{item.id === selectedId ? (
<div className="absolute right-3 top-3 rounded-full bg-[#063e8e] px-2 py-1 text-xs font-medium text-white">
Đã chọn
?? ch?n
</div>
) : null}
</div>
......
......@@ -521,9 +521,14 @@ export function AdminNewsForm({
title: form.title.trim(),
slug: slugifyAdminNews(form.slug.trim()),
summary: form.summary,
type: form.type,
header_category_id: form.header_category_id,
category_ids:
form.type === "baiviettrang" ? [] : selectedHeaderCategory?.category_ids ?? [],
form.type === "baiviettrang"
? form.header_category_id
? [form.header_category_id]
: []
: selectedHeaderCategory?.category_ids ?? [],
tag_ids: form.type === "baiviettrang" ? [] : selectedTagIds,
is_featured: form.type === "tintuc" ? form.is_featured : false,
thumbnail_id: form.thumbnail && isUuid(form.thumbnail.id) ? form.thumbnail.id : null,
......@@ -812,30 +817,31 @@ export function AdminNewsForm({
</div>
) : null}
{availableSearchTags.length > 0 ? (
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4">
<Label className="mb-3 block text-gray-700">Tag tìm kiếm</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{availableSearchTags.map((item) => (
<label
key={item}
className="flex items-center gap-3 rounded-lg border border-[#063e8e]/10 bg-white px-3 py-2"
>
<Checkbox
checked={form.tagsearch_values.includes(item)}
onCheckedChange={(checked) =>
handleToggleSearchTag(item, checked === true)
}
className="border-[#063e8e]/30 data-[state=checked]:border-[#063e8e] data-[state=checked]:bg-[#063e8e]"
/>
<span className="text-sm text-gray-700">{item}</span>
</label>
))}
</div>
</div>
) : null}
</div>
</div>
{availableSearchTags.length > 0 ? (
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4 xl:col-span-2">
<Label className="mb-3 block text-gray-700">Tag tìm kiếm</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{availableSearchTags.map((item) => (
<label
key={item}
className="flex items-center gap-3 rounded-lg border border-[#063e8e]/10 bg-white px-3 py-2"
>
<Checkbox
checked={form.tagsearch_values.includes(item)}
onCheckedChange={(checked) =>
handleToggleSearchTag(item, checked === true)
}
className="border-[#063e8e]/30 data-[state=checked]:border-[#063e8e] data-[state=checked]:bg-[#063e8e]"
/>
<span className="text-sm text-gray-700">{item}</span>
</label>
))}
</div>
</div>
) : null}
</div>
</FormSection>
......
import dayjs from "dayjs";
import Links from "@links/index";
import { NewsItem } from "@/api/types/news";
import { NewsItem } from '@/api/types/news';
import Links from '@links/index'
import dayjs from 'dayjs';
// Helper: remove <img> tags and extract plain text from HTML
const stripImagesAndHtml = (html?: string) => {
if (!html) return ''
// remove img tags first
const withoutImgs = html.replace(/<img[^>]*>/gi, '')
// use DOMParser on client for robust extraction
if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') {
if (!html) return "";
const withoutImages = html.replace(/<img[^>]*>/gi, "");
if (typeof window !== "undefined" && typeof DOMParser !== "undefined") {
try {
const doc = new DOMParser().parseFromString(withoutImgs, 'text/html')
return doc.body.textContent || ''
const doc = new DOMParser().parseFromString(withoutImages, "text/html");
return doc.body.textContent || "";
} catch {
// fallback to regex
return withoutImages.replace(/<[^>]*>/g, "");
}
}
return withoutImgs.replace(/<[^>]*>/g, '')
}
const CardNews = ({ news, link }: { news: NewsItem, link: string }) => {
return withoutImages.replace(/<[^>]*>/g, "");
};
const resolveThumbnail = (thumbnail?: string) => {
if (!thumbnail) return "/img-error.png";
if (thumbnail.startsWith("http://") || thumbnail.startsWith("https://")) return thumbnail;
if (thumbnail.startsWith("/")) return `${Links.imageEndpoint.replace(/\/+$/, "")}${thumbnail}`;
return `${Links.imageEndpoint}${thumbnail}`;
};
const CardNews = ({ news, link }: { news: NewsItem; link: string }) => {
return (
<a
href={`${link}`}
href={link}
className="flex flex-col hover:no-underline sm:flex-row gap-2 mb-6 bg-white rounded-lg shadow-sm p-4 border items-start min-w-0"
>
<img
src={`${Links.imageEndpoint}${news.thumbnail}`}
src={resolveThumbnail(news.thumbnail)}
alt={news.title}
className="w-full sm:w-56 md:w-64 h-40 md:h-36 object-cover shrink-0"
onError={(e) => {
e.currentTarget.src = "/img-error.png"
e.currentTarget.src = "/img-error.png";
}}
/>
<div className="flex-1 min-w-0 pl-0 sm:pl-4">
<p className="text-primary font-semibold text-base md:text-lg hover:underline line-clamp-2 wrap-break-word">
<p className="text-primary font-semibold text-base md:text-lg hover:underline line-clamp-2 break-words">
{news.title}
</p>
<div className="text-sm my-2 text-[#00AED5]">{dayjs(news.release_at).format('DD/MM/YYYY')}</div>
<div className="text-sm my-2 text-[#00AED5]">
{dayjs(news.release_at).format("DD/MM/YYYY")}
</div>
<div className="text-sm text-[#777] line-clamp-3">
<div className="text-sm prose tiptap">{stripImagesAndHtml(news.description)}</div>
<div className="text-sm prose tiptap">
{stripImagesAndHtml(news.description)}
</div>
</div>
</div>
</a>
)
}
);
};
export default CardNews;
\ No newline at end of file
export default CardNews;
......@@ -7,31 +7,29 @@ import { MenuItem } from "../menu-category";
type Category = {
id: string;
name: string;
static_link: string;
static_link?: string;
url?: string;
};
// Default categories removed — component now accepts `categories` via props.
const resolveHref = (category: Category) => category.static_link ?? category.url ?? "#";
const ListCategory: React.FC<{ categories?: Category[] }> = ({
categories = [],
}) => {
const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }) => {
const pathname = usePathname() || "";
const isActive = (href: string) => {
return pathname === href;
};
const isActive = (href: string) => pathname === href;
return (
<div className="border-t border-gray-200 bg-white py-2">
<div className="w-full px-4 sm:px-6 lg:px-8">
<div className="py-3">
<div className="flex flex-wrap items-center max-w-full overflow-x-auto">
{categories.map((c) => {
const menu = { id: c.id, name: c.name, link: c.static_link };
const active = isActive(c.static_link);
{categories.map((category) => {
const href = resolveHref(category);
const menu = { id: category.id, name: category.name, link: href };
const active = isActive(href);
return (
<div key={c.id} className="shrink-0">
<div key={category.id} className="shrink-0">
<MenuItem menu={menu} active={active} />
</div>
);
......
"use client"
import React, { useState, useEffect } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
"use client";
type Category = { id: string; title: string; count: number }
import React, { useEffect, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
type Category = { id: string; title: string; count: number };
export const ListFilter: React.FC<{
categories?: Category[]
onSearch?: (q: string) => void
onReset?: () => void
categories?: Category[];
onSearch?: (q: string) => void;
onReset?: () => void;
}> = ({ categories, onSearch, onReset }) => {
const [query, setQuery] = useState('')
const [visibleCount, setVisibleCount] = useState(5)
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(5);
const [selected, setSelected] = useState<Record<string, boolean>>(() => {
const map: Record<string, boolean> = {}
if (categories && categories.length) {
categories.forEach((c: Category) => (map[c.id] = false))
const map: Record<string, boolean> = {};
if (categories?.length) {
categories.forEach((category) => {
map[category.id] = false;
});
}
return map
})
return map;
});
// Keep selected map in sync when categories prop changes.
// Defer setSelected to avoid calling setState synchronously inside the effect.
useEffect(() => {
const timer = setTimeout(() => {
setSelected((prev) => {
const map: Record<string, boolean> = {}
if (categories && categories.length) {
categories.forEach((c: Category) => (map[c.id] = !!prev[c.id]))
const map: Record<string, boolean> = {};
if (categories?.length) {
categories.forEach((category) => {
map[category.id] = Boolean(prev[category.id]);
});
}
return map
})
}, 0)
return () => clearTimeout(timer)
}, [categories])
return map;
});
}, 0);
return () => clearTimeout(timer);
}, [categories]);
const toggle = (id: string) => setSelected((s) => ({ ...s, [id]: !s[id] }))
const toggle = (id: string) => setSelected((current) => ({ ...current, [id]: !current[id] }));
return (
<aside className="p-6 bg-white border rounded-md">
......@@ -44,74 +48,82 @@ export const ListFilter: React.FC<{
<div className="mb-4">
<Input
placeholder="Tên văn bản ..."
placeholder="Tên văn bản..."
value={query}
className='text-black placeholder:text-gray-400 rounded-none py-2.5 px-2'
className="text-black placeholder:text-gray-400 rounded-none py-2.5 px-2"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onSearch?.(query)
if (e.key === "Enter") {
onSearch?.(query);
}
}}
/>
</div>
<div className="flex flex-col gap-3">
{categories && categories.length > 0 ? (
categories.slice(0, visibleCount).map((c) => (
<label key={c.id} className="flex items-center gap-3">
<Checkbox checked={!!selected[c.id]} onCheckedChange={() => toggle(c.id)} />
<div className="flex justify-between w-full items-center">
<span className="text-sm">{c.title}</span>
<span className="text-sm text-gray-400">({c.count})</span>
</div>
</label>
))
) : null}
{categories?.length
? categories.slice(0, visibleCount).map((category) => (
<label key={category.id} className="flex items-center gap-3">
<Checkbox
checked={Boolean(selected[category.id])}
onCheckedChange={() => toggle(category.id)}
/>
<div className="flex justify-between w-full items-center">
<span className="text-sm">{category.title}</span>
<span className="text-sm text-gray-400">({category.count})</span>
</div>
</label>
))
: null}
<div className="mt-2 flex items-center gap-3">
{(categories?.length ?? 0) > visibleCount && (
{(categories?.length ?? 0) > visibleCount ? (
<button
className="text-sm text-primary self-start"
onClick={() => setVisibleCount((v) => v + 5)}
onClick={() => setVisibleCount((current) => current + 5)}
>
Xem thêm
</button>
)}
) : null}
{visibleCount > 5 && (
{visibleCount > 5 ? (
<button
className="text-sm text-gray-500 self-start"
onClick={() => setVisibleCount(5)}
>
Thu gọn
</button>
)}
) : null}
</div>
</div>
<div className="flex gap-3">
<Button className="flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary" onClick={() => onSearch?.(query)}>
<Button
className="flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
onClick={() => onSearch?.(query)}
>
Tìm kiếm
</Button>
<Button
className="flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
onClick={() => {
setQuery('')
// restore initial map
const map: Record<string, boolean> = {}
if (categories && categories.length) {
categories.forEach((c) => (map[c.id] = false))
}
setSelected(map)
setVisibleCount(5)
onReset?.()
}}
onClick={() => {
setQuery("");
const map: Record<string, boolean> = {};
if (categories?.length) {
categories.forEach((category) => {
map[category.id] = false;
});
}
setSelected(map);
setVisibleCount(5);
onReset?.();
}}
>
Bỏ tìm
</Button>
</div>
</aside>
)
}
);
};
export default ListFilter
export default ListFilter;
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ensureValidAdminAccessToken } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore";
const LOGIN_PATH = "/admin/login";
......@@ -71,20 +72,62 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const accessToken = useAuthStore((state) => state.appAccessToken);
const accessTokenExpired = useAuthStore((state) => state.appAccessTokenExpired);
const refreshToken = useAuthStore((state) => state.appRefreshToken);
const isRefreshing = useAuthStore((state) => state.appIsRefreshing);
const [isRestoringSession, setIsRestoringSession] = useState(false);
useEffect(() => {
if (!hasHydrated || pathname === LOGIN_PATH) return;
if (!isLoggedIn || !accessToken) {
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
}
}, [accessToken, hasHydrated, isLoggedIn, pathname, router]);
let cancelled = false;
const restoreSession = async () => {
const needsRefresh = Boolean(
accessToken &&
accessTokenExpired &&
accessTokenExpired <= Date.now() &&
refreshToken,
);
if (accessToken && isLoggedIn && !needsRefresh) return;
if (!refreshToken) {
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
return;
}
setIsRestoringSession(true);
try {
const nextToken = await ensureValidAdminAccessToken();
if (!nextToken && !cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
}
} catch {
if (!cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
}
} finally {
if (!cancelled) {
setIsRestoringSession(false);
}
}
};
void restoreSession();
return () => {
cancelled = true;
};
}, [accessToken, accessTokenExpired, hasHydrated, isLoggedIn, pathname, refreshToken, router]);
if (pathname === LOGIN_PATH) {
return <>{children}</>;
}
if (!hasHydrated) {
if (!hasHydrated || isRefreshing || isRestoringSession) {
return <AdminAuthLoadingScreen />;
}
......
'use client';
import React from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { LogOut, Menu, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { deleteAuthLogout } from '@/api/endpoints/auth';
import { logoutAdmin } from '@/lib/auth/admin-auth';
import { useSidebarStore } from '@/hooks/use-admin-sidebar';
import useAuthStore from '@/store/useAuthStore';
......@@ -40,23 +39,30 @@ function getTitle(pathname: string): string {
return 'Quản trị';
}
function formatPrimaryRole(role?: string) {
if (!role) return currentUserRoleLabel;
return role
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function formatRoles(roles?: string[]) {
if (!roles || roles.length === 0) return currentUserRoleLabel;
return roles.map((role) => formatPrimaryRole(role)).join(', ');
}
export function AdminHeader() {
const { toggle } = useSidebarStore();
const pathname = usePathname();
const router = useRouter();
const title = getTitle(pathname);
const resetStore = useAuthStore((state) => state.resetStore);
const currentUser = useAuthStore((state) => state.appUser);
const handleLogout = async () => {
try {
await deleteAuthLogout();
} catch {
// Ignore API logout failure and continue clearing local state.
} finally {
resetStore();
toast.success('Đã đăng xuất khỏi trang quản trị');
router.replace('/admin/login');
}
await logoutAdmin({ redirectToLogin: true });
};
return (
......@@ -78,7 +84,7 @@ export function AdminHeader() {
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 rounded-full border border-[#063e8e]/10 bg-[#f8fbff] px-3 py-1.5 text-sm font-medium text-[#163b73]">
<ShieldCheck className="h-4 w-4 text-[#063e8e]" />
<span>{currentUserRoleLabel}</span>
<span>{formatRoles(currentUser?.roles)}</span>
</div>
<Button
variant="outline"
......
......@@ -29,7 +29,7 @@ const AppEditorContent: FC<AppEditorContentProps> = ({ value = '', className = '
// 3. ✅ Xóa thẻ <a> nhưng giữ lại nội dung
if (tagName === 'a') {
// Trả về children đã được xử lý (làm phẳng thẻ <a>)
// Tr? v? children d? du?c x? l? (l?m ph?ng th? <a>)
return <>{children}</>;
}
......
......@@ -2,7 +2,6 @@
import { useCustomClient } from "@/api/mutator/custom-client";
import { categoryFallbackRows } from "@/mockdata/categories";
import useAuthStore from "@/store/useAuthStore";
export type CmsHeaderCategoryType = "category" | "page" | "news";
......@@ -189,16 +188,11 @@ const readMessage = (payload: unknown) => {
const authHeaders = (withJson = true) => {
const headers = new Headers();
const token = useAuthStore.getState().appAccessToken;
if (withJson) {
headers.set("Content-Type", "application/json");
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers;
};
......@@ -341,7 +335,9 @@ const transformPost = (
slug: post.slug ?? "",
summary: post.summary ?? "",
type:
primaryCategoryType === "post" || primaryCategoryType === "page"
post.type === "page" ||
primaryCategoryType === "post" ||
primaryCategoryType === "page"
? "baiviettrang"
: "tintuc",
header_category_id: primaryCategory?.id ?? "",
......@@ -550,6 +546,29 @@ export async function fetchCmsTags() {
return fetchAllTagsInternal();
}
export async function fetchCmsTagsPage(params?: {
page?: number;
pageSize?: number;
}) {
const searchParams = new URLSearchParams({
page: String(params?.page ?? 1),
pageSize: String(params?.pageSize ?? 10),
sortField: "name",
sortOrder: "ASC",
});
const result = await cmsRequest<CmsPagedResult<CmsTagItem>>(
`/tag?${searchParams.toString()}`,
);
return {
items: result.rows ?? [],
total: result.count ?? 0,
page: result.page ?? params?.page ?? 1,
pageSize: result.pageSize ?? params?.pageSize ?? 10,
};
}
export async function createCmsTag(input: { name: string; slug?: string }) {
return cmsRequest<CmsTagItem>("/tag", {
method: "POST",
......@@ -723,6 +742,7 @@ export async function fetchCmsNewsItems(params?: {
pageSize?: number;
sortField?: string;
sortOrder?: string;
filters?: string;
}) {
const queryParams = new URLSearchParams({
page: String(params?.page ?? 1),
......@@ -731,6 +751,10 @@ export async function fetchCmsNewsItems(params?: {
sortOrder: params?.sortOrder ?? "desc",
});
if (params?.filters?.trim()) {
queryParams.set("filters", params.filters.trim());
}
const result = await cmsRequest<CmsPagedResult<CmsRawPostItem>>(
`/post?${queryParams.toString()}`,
);
......@@ -757,6 +781,7 @@ export async function createCmsNewsItem(input: {
title: string;
slug: string;
summary: string;
type: "tintuc" | "baiviettrang";
header_category_id: string;
category_ids: string[];
tag_ids: string[];
......@@ -776,6 +801,7 @@ export async function createCmsNewsItem(input: {
title: input.title,
slug: input.slug,
summary: input.summary,
type: input.type === "baiviettrang" ? "page" : "news",
external_link: input.slug ? `/${input.slug}` : "/",
content: input.summary || "",
category_ids: input.category_ids,
......@@ -816,6 +842,7 @@ export async function updateCmsNewsItem(
title: string;
slug: string;
summary: string;
type: "tintuc" | "baiviettrang";
header_category_id: string;
category_ids: string[];
tag_ids: string[];
......@@ -836,6 +863,7 @@ export async function updateCmsNewsItem(
title: input.title,
slug: input.slug,
summary: input.summary,
type: input.type === "baiviettrang" ? "page" : "news",
external_link: input.slug ? `/${input.slug}` : "/",
content: input.summary || "",
category_ids: input.category_ids,
......
"use client";
import { toast } from "sonner";
import useAuthStore, {
type AuthenticatedAdminSession,
type AuthenticatedAdminUser,
} from "@/store/useAuthStore";
const AUTH_BASE_URL = `${process.env.NEXT_PUBLIC_BACKEND_HOST}/api/v1.0/auth`;
const SESSION_EXPIRED_MESSAGE = "Phi?n dang nh?p d? h?t h?n. Vui l?ng dang nh?p l?i.";
interface AuthEnvelope<T> {
message?: string | null;
message_en?: string | null;
responseData?: T;
data?: {
responseData?: T;
};
}
interface AuthErrorPayload {
message?: string | null;
message_en?: string | null;
error?: {
message?: {
vi?: string | null;
en?: string | null;
};
};
}
interface LoginResponseData {
user?: Partial<AuthenticatedAdminUser> | null;
session?: Partial<AuthenticatedAdminSession> | null;
access_token?: string | null;
refresh_token?: string | null;
expires_in?: number | null;
token_type?: string | null;
}
interface MeResponseData extends Partial<AuthenticatedAdminUser> {}
interface RefreshResponseData {
session?: Partial<AuthenticatedAdminSession> | null;
access_token?: string | null;
refresh_token?: string | null;
expires_in?: number | null;
token_type?: string | null;
}
interface AuthRequestOptions extends RequestInit {
skipAuthHeader?: boolean;
}
type AuthFailureReason = "missing_refresh_token" | "refresh_failed";
let refreshPromise: Promise<string | null> | null = null;
let forcedLogoutPromise: Promise<void> | null = null;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const getEnvelopeData = <T>(payload: AuthEnvelope<T>) =>
payload.responseData ?? payload.data?.responseData;
const getErrorMessage = (payload: unknown, fallback: string) => {
if (!isObject(payload)) return fallback;
const apiPayload = payload as AuthErrorPayload;
return (
apiPayload.error?.message?.vi ??
apiPayload.message ??
apiPayload.message_en ??
fallback
);
};
const normalizeUser = (user?: Partial<AuthenticatedAdminUser> | null): AuthenticatedAdminUser | null => {
if (!user?.id || !user.email || !user.username) return null;
return {
id: user.id,
email: user.email,
username: user.username,
first_name: user.first_name ?? null,
last_name: user.last_name ?? null,
roles: Array.isArray(user.roles) ? user.roles.filter((value): value is string => typeof value === "string") : [],
permissions: Array.isArray(user.permissions)
? user.permissions.filter((value): value is string => typeof value === "string")
: [],
status: user.status ?? null,
last_login_at: user.last_login_at ?? null,
};
};
const normalizeSession = (
session?: Partial<AuthenticatedAdminSession> | null,
): AuthenticatedAdminSession | null => {
if (!session) return null;
return {
id: typeof session.id === "string" ? session.id : null,
expires_at: typeof session.expires_at === "string" ? session.expires_at : null,
refresh_expires_at:
typeof session.refresh_expires_at === "string" ? session.refresh_expires_at : null,
};
};
async function requestAuth<T>(
path: string,
init?: AuthRequestOptions,
): Promise<T> {
const headers = new Headers(init?.headers);
headers.set("Content-Type", "application/json");
if (!init?.skipAuthHeader) {
const token = useAuthStore.getState().appAccessToken;
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
}
const response = await fetch(`${AUTH_BASE_URL}${path}`, {
...init,
credentials: "include",
headers,
});
const data = (await response.json().catch(() => ({}))) as AuthEnvelope<T> & AuthErrorPayload;
if (!response.ok) {
const error = new Error(getErrorMessage(data, "Yêu cầu xác thực thất bại.")) as Error & {
status?: number;
payload?: unknown;
};
error.status = response.status;
error.payload = data;
throw error;
}
return getEnvelopeData(data) as T;
}
const redirectToLogin = () => {
if (typeof window === "undefined") return;
const currentPath = `${window.location.pathname}${window.location.search}`;
const redirect = currentPath.startsWith("/admin") && currentPath !== "/admin/login"
? `?redirect=${encodeURIComponent(currentPath)}`
: "";
window.location.replace(`/admin/login${redirect}`);
};
const markSessionExpiredAndNotify = () => {
const store = useAuthStore.getState();
if (!store.appSessionExpiredNotified) {
store.markSessionExpiredNotified(true);
toast.error(SESSION_EXPIRED_MESSAGE);
}
};
export async function loginAdmin(email: string, password: string) {
const payload = await requestAuth<LoginResponseData>("/login", {
method: "POST",
body: JSON.stringify({
email,
password,
}),
skipAuthHeader: true,
});
if (!payload.access_token || !payload.refresh_token || !payload.expires_in) {
throw new Error("Thiếu dữ liệu phiên đăng nhập từ API.");
}
const me = await requestAuth<MeResponseData>("/me", {
method: "GET",
}).catch(() => payload.user ?? null);
const normalizedUser = normalizeUser(me ?? payload.user);
useAuthStore.getState().setAuthSession({
accessToken: payload.access_token,
refreshToken: payload.refresh_token,
expiresIn: payload.expires_in,
user: normalizedUser,
session: normalizeSession(payload.session),
});
useAuthStore.getState().setAppUser(normalizedUser);
return payload;
}
export async function logoutAdmin(options?: {
silent?: boolean;
redirectToLogin?: boolean;
reason?: AuthFailureReason;
}) {
const { silent = false, redirectToLogin: shouldRedirect = true, reason } = options ?? {};
if (forcedLogoutPromise) {
return forcedLogoutPromise;
}
forcedLogoutPromise = (async () => {
const store = useAuthStore.getState();
const refreshToken = store.appRefreshToken;
try {
await requestAuth("/logout", {
method: "DELETE",
});
} catch {
// Ignore logout API failure and continue clearing local auth state.
} finally {
if (reason === "refresh_failed") {
markSessionExpiredAndNotify();
}
useAuthStore.getState().resetStore();
if (reason === "refresh_failed") {
useAuthStore.getState().markSessionExpiredNotified(true);
} else if (!silent) {
toast.success("Đã đăng xuất khỏi trang quản trị");
}
if (shouldRedirect) {
redirectToLogin();
}
}
})();
try {
await forcedLogoutPromise;
} finally {
forcedLogoutPromise = null;
}
}
export async function refreshAdminAccessToken() {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
const store = useAuthStore.getState();
const refreshToken = store.appRefreshToken;
if (!refreshToken) {
await logoutAdmin({ silent: true, reason: "missing_refresh_token" });
return null;
}
store.setAppRefreshing(true);
try {
const payload = await requestAuth<RefreshResponseData>("/refresh", {
method: "POST",
body: JSON.stringify({
refresh_token: refreshToken,
}),
skipAuthHeader: true,
});
if (!payload.access_token || !payload.expires_in) {
throw new Error("Thiếu access token mới từ API.");
}
useAuthStore.getState().updateAccessToken({
accessToken: payload.access_token,
expiresIn: payload.expires_in,
refreshToken: payload.refresh_token ?? refreshToken,
session: normalizeSession(payload.session),
});
return payload.access_token;
} catch (error) {
await logoutAdmin({ silent: true, reason: "refresh_failed" });
throw error;
} finally {
useAuthStore.getState().setAppRefreshing(false);
refreshPromise = null;
}
})();
return refreshPromise;
}
export async function ensureValidAdminAccessToken() {
const store = useAuthStore.getState();
if (!store.appAccessToken) {
if (store.appRefreshToken) {
return refreshAdminAccessToken();
}
return null;
}
if (!store.appAccessTokenExpired || store.appAccessTokenExpired > Date.now()) {
return store.appAccessToken;
}
return refreshAdminAccessToken();
}
export async function handleAdminUnauthorized() {
await logoutAdmin({ silent: true, reason: "refresh_failed" });
}
export const adminSessionExpiredMessage = SESSION_EXPIRED_MESSAGE;
......@@ -123,6 +123,25 @@ export const EMPTY_ADMIN_NEWS_FORM: AdminNewsFormValues = {
post_content: [],
};
const mediaSeedLabelMap = {
"media-banner": {
name: "Banner VCCI News",
alt: "Banner VCCI News",
},
"media-thumbnail": {
name: "Thumbnail mặc định",
alt: "Thumbnail mặc định",
},
"media-home-01": {
name: "Hoạt động hội viên",
alt: "Hoạt động hội viên",
},
"media-home-02": {
name: "Banner sự kiện",
alt: "Banner sự kiện",
},
} as const;
const mediaSeed: AdminMediaItem[] = [
{
id: "media-banner",
......@@ -179,17 +198,31 @@ function toImageRef(item: AdminMediaItem): AdminNewsImageRef {
};
}
function normalizeSeedMediaLabels(item: AdminMediaItem): AdminMediaItem {
const seedLabel = mediaSeedLabelMap[item.id as keyof typeof mediaSeedLabelMap];
if (!seedLabel) {
return item;
}
return {
...item,
name: seedLabel.name,
alt: seedLabel.alt,
};
}
const newsSeed: AdminNewsItem[] = [
{
id: "admin-news-01",
title: "VCCI thúc đẩy kết nối doanh nghiệp hội viên khu vực phía Nam",
title: "VCCI th�c d?y k?t n?i doanh nghi?p h?i vi�n khu v?c ph�a Nam",
slug: "vcci-thuc-day-ket-noi-doanh-nghiep-hoi-vien-khu-vuc-phia-nam",
summary:
"<p>Bản tin tổng hợp các hoạt động kết nối doanh nghiệp, mở rộng thị trường và nâng cao năng lực quản trị cho hội viên VCCI.</p>",
"<p>B?n tin t?ng h?p c�c ho?t d?ng k?t n?i doanh nghi?p, m? r?ng th? tru?ng v� n�ng cao nang l?c qu?n tr? cho h?i vi�n VCCI.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news", "cat-activity"],
tagsearch_values: ["Tin VCCI", "Doanh nghiệp hội viên", "Chuyển đổi số"],
tagsearch_values: ["Tin VCCI", "Doanh nghi?p h?i vi�n", "Chuy?n d?i s?"],
is_featured: true,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -200,15 +233,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
participation_fee: "Miễn phí",
location: "TP. H? Ch� Minh",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-01-a",
type: "text",
position: 1,
content:
"<p>Chương trình tập trung vào các giải pháp mở rộng mạng lưới doanh nghiệp hội viên, đồng thời hỗ trợ các đơn vị tiếp cận cơ hội hợp tác mới trong năm 2026.</p>",
"<p>Chuong tr�nh t?p trung v�o c�c gi?i ph�p m? r?ng m?ng lu?i doanh nghi?p h?i vi�n, d?ng th?i h? tr? c�c don v? ti?p c?n co h?i h?p t�c m?i trong nam 2026.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -229,14 +262,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-02",
title: "Lịch hội thảo chuyển đổi số dành cho hội viên tháng 5",
title: "L?ch h?i th?o chuy?n d?i s? d�nh cho h?i vi�n th�ng 5",
slug: "lich-hoi-thao-chuyen-doi-so-danh-cho-hoi-vien-thang-5",
summary:
"<p>Lịch hội thảo cập nhật những chương trình đào tạo, chia sẻ chuyên đề và kết nối nguồn lực hỗ trợ doanh nghiệp.</p>",
"<p>L?ch h?i th?o c?p nh?t nh?ng chuong tr�nh d�o t?o, chia s? chuy�n d? v� k?t n?i ngu?n l?c h? tr? doanh nghi?p.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Sự kiện", "Đăng ký"],
tagsearch_values: ["S? ki?n", "�ang k�"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -247,15 +280,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-20T08:00",
ended_at: "2026-05-20T17:00",
registration_deadline: "2026-05-18T17:00",
location: "Trung tâm Hội nghị VCCI",
participation_fee: "500.000 VNĐ",
location: "Trung t�m H?i ngh? VCCI",
participation_fee: "500.000 VN",
post_content: [
{
id: "section-admin-news-02-a",
type: "text",
position: 1,
content:
"<p>Nội dung chuỗi hội thảo bao gồm chuyển đổi số, quản trị dữ liệu, truyền thông nội bộ và ứng dụng AI trong hoạt động doanh nghiệp.</p>",
"<p>N?i dung chu?i h?i th?o bao g?m chuy?n d?i s?, qu?n tr? d? li?u, truy?n th�ng n?i b? v� ?ng d?ng AI trong ho?t d?ng doanh nghi?p.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -264,10 +297,10 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-03",
title: "Giới thiệu vai trò của VCCI News trong hệ sinh thái nội dung số",
title: "Gi?i thi?u vai tr� c?a VCCI News trong h? sinh th�i n?i dung s?",
slug: "gioi-thieu-vai-tro-cua-vcci-news-trong-he-sinh-thai-noi-dung-so",
summary:
"<p>Bài viết trang giới thiệu định hướng phát triển nội dung, cấu trúc quản trị và trải nghiệm người dùng trên website.</p>",
"<p>B�i vi?t trang gi?i thi?u d?nh hu?ng ph�t tri?n n?i dung, c?u tr�c qu?n tr? v� tr?i nghi?m ngu?i d�ng tr�n website.</p>",
type: "baiviettrang",
header_category_id: "intro-about",
category_ids: [],
......@@ -290,7 +323,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>VCCI News được định hướng là trung tâm cập nhật thông tin, chuyên đề và hoạt động hội viên trên cùng một nền tảng nội dung thống nhất.</p>",
"<p>VCCI News du?c d?nh hu?ng l� trung t�m c?p nh?t th�ng tin, chuy�n d? v� ho?t d?ng h?i vi�n tr�n c�ng m?t n?n t?ng n?i dung th?ng nh?t.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -308,14 +341,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-04",
title: "Diễn đàn xúc tiến thương mại khu vực phía Nam thu hút hơn 300 doanh nghiệp tham dự",
title: "Di?n d�n x�c ti?n thuong m?i khu v?c ph�a Nam thu h�t hon 300 doanh nghi?p tham d?",
slug: "dien-dan-xuc-tien-thuong-mai-khu-vuc-phia-nam-thu-hut-hon-300-doanh-nghiep-tham-du",
summary:
"<p>Chương trình quy tụ doanh nghiệp sản xuất, logistics và các đơn vị hỗ trợ xuất khẩu nhằm tạo mạng lưới kết nối giao thương thực chất.</p>",
"<p>Chuong tr�nh quy t? doanh nghi?p s?n xu?t, logistics v� c�c don v? h? tr? xu?t kh?u nh?m t?o m?ng lu?i k?t n?i giao thuong th?c ch?t.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news", "cat-activity"],
tagsearch_values: ["Tin Kinh Tế", "Xúc tiến thương mại", "Kết nối giao thương"],
tagsearch_values: ["Tin Kinh T?", "X�c ti?n thuong m?i", "K?t n?i giao thuong"],
is_featured: true,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -326,15 +359,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Trung tâm Hội chợ và Triển lãm Sài Gòn",
participation_fee: "Miễn phí",
location: "Trung t�m H?i ch? v� Tri?n l�m S�i G�n",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-04-a",
type: "text",
position: 1,
content:
"<p>Sự kiện nhấn mạnh nhu cầu tạo chuỗi kết nối ngắn, nhanh và có khả năng chuyển hóa thành cơ hội kinh doanh ngay sau chương trình.</p>",
"<p>S? ki?n nh?n m?nh nhu c?u t?o chu?i k?t n?i ng?n, nhanh v� c� kh? nang chuy?n h�a th�nh co h?i kinh doanh ngay sau chuong tr�nh.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -343,14 +376,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-05",
title: "Bản tin nhanh: doanh nghiệp hội viên tăng tốc chuyển đổi số trong khâu bán hàng và chăm sóc khách hàng",
title: "B?n tin nhanh: doanh nghi?p h?i vi�n tang t?c chuy?n d?i s? trong kh�u b�n h�ng v� cham s�c kh�ch h�ng",
slug: "ban-tin-nhanh-doanh-nghiep-hoi-vien-tang-toc-chuyen-doi-so-trong-khau-ban-hang-va-cham-soc-khach-hang",
summary:
"<p>Nhiều mô hình ứng dụng CRM, dashboard và tự động hóa quy trình đang được chia sẻ tại chuỗi chuyên đề của VCCI News.</p>",
"<p>Nhi?u m� h�nh ?ng d?ng CRM, dashboard v� t? d?ng h�a quy tr�nh dang du?c chia s? t?i chu?i chuy�n d? c?a VCCI News.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news"],
tagsearch_values: ["Chuyên Đề", "Chuyển đổi số"],
tagsearch_values: ["Chuy�n �?", "Chuy?n d?i s?"],
is_featured: true,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -361,15 +394,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Hệ thống trực tuyến",
participation_fee: "Miễn phí",
location: "H? th?ng tr?c tuy?n",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-05-a",
type: "text",
position: 1,
content:
"<p>Xu hướng tập trung vào trải nghiệm khách hàng, đo lường hiệu quả vận hành và chuẩn hóa dữ liệu đang trở thành ưu tiên hàng đầu.</p>",
"<p>Xu hu?ng t?p trung v�o tr?i nghi?m kh�ch h�ng, do lu?ng hi?u qu? v?n h�nh v� chu?n h�a d? li?u dang tr? th�nh uu ti�n h�ng d?u.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -378,14 +411,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-06",
title: "Khởi động chuỗi đối thoại chính sách với doanh nghiệp và hiệp hội ngành hàng năm 2026",
title: "Kh?i d?ng chu?i d?i tho?i ch�nh s�ch v?i doanh nghi?p v� hi?p h?i ng�nh h�ng nam 2026",
slug: "khoi-dong-chuoi-doi-thoai-chinh-sach-voi-doanh-nghiep-va-hiep-hoi-nganh-hang-nam-2026",
summary:
"<p>Chuỗi đối thoại sẽ tập trung vào vướng mắc thủ tục, chi phí tuân thủ và các đề xuất cải thiện môi trường kinh doanh.</p>",
"<p>Chu?i d?i tho?i s? t?p trung v�o vu?ng m?c th? t?c, chi ph� tu�n th? v� c�c d? xu?t c?i thi?n m�i tru?ng kinh doanh.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event", "cat-policy"],
tagsearch_values: ["Sự kiện", "Chính sách"],
tagsearch_values: ["S? ki?n", "Ch�nh s�ch"],
is_featured: true,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
......@@ -396,15 +429,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-28T08:30",
ended_at: "2026-05-28T12:00",
registration_deadline: "2026-05-25T17:00",
location: "Hà Nội",
participation_fee: "Miễn phí",
location: "H� N?i",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-06-a",
type: "text",
position: 1,
content:
"<p>Chương trình được thiết kế như một không gian lắng nghe phản hồi thực tiễn và tạo đầu mối điều phối cho các kiến nghị có trọng tâm.</p>",
"<p>Chuong tr�nh du?c thi?t k? nhu m?t kh�ng gian l?ng nghe ph?n h?i th?c ti?n v� t?o d?u m?i di?u ph?i cho c�c ki?n ngh? c� tr?ng t�m.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -413,14 +446,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-07",
title: "Cẩm nang thiết kế gian hàng triển lãm hiệu quả cho doanh nghiệp tham gia hội chợ quốc tế",
title: "C?m nang thi?t k? gian h�ng tri?n l�m hi?u qu? cho doanh nghi?p tham gia h?i ch? qu?c t?",
slug: "cam-nang-thiet-ke-gian-hang-trien-lam-hieu-qua-cho-doanh-nghiep-tham-gia-hoi-cho-quoc-te",
summary:
"<p>Nội dung tổng hợp những lưu ý về nhận diện thương hiệu, luồng trưng bày và cách tạo trải nghiệm ghi nhớ cho khách tham quan.</p>",
"<p>N?i dung t?ng h?p nh?ng luu � v? nh?n di?n thuong hi?u, lu?ng trung b�y v� c�ch t?o tr?i nghi?m ghi nh? cho kh�ch tham quan.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news", "cat-activity"],
tagsearch_values: ["Chuyên Đề", "Cẩm nang"],
tagsearch_values: ["Chuy�n �?", "C?m nang"],
is_featured: true,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -431,15 +464,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
participation_fee: "Miễn phí",
location: "TP. H? Ch� Minh",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-07-a",
type: "text",
position: 1,
content:
"<p>Tài liệu hướng dẫn được biên tập để doanh nghiệp có thể ứng dụng ngay khi chuẩn bị tham gia các sự kiện giao thương trong nước và quốc tế.</p>",
"<p>T�i li?u hu?ng d?n du?c bi�n t?p d? doanh nghi?p c� th? ?ng d?ng ngay khi chu?n b? tham gia c�c s? ki?n giao thuong trong nu?c v� qu?c t?.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -448,14 +481,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-08",
title: "Hoa Kỳ muốn đẩy mạnh hợp tác kinh tế, thương mại bền vững với Việt Nam",
title: "Hoa K? mu?n d?y m?nh h?p t�c kinh t?, thuong m?i b?n v?ng v?i Vi?t Nam",
slug: "hoa-ky-muon-day-manh-hop-tac-kinh-te-thuong-mai-ben-vung-voi-viet-nam",
summary:
"<p>Chương trình làm việc tập trung vào hợp tác chuỗi cung ứng, tiêu chuẩn xanh và kết nối doanh nghiệp giữa các địa phương.</p>",
"<p>Chuong tr�nh l�m vi?c t?p trung v�o h?p t�c chu?i cung ?ng, ti�u chu?n xanh v� k?t n?i doanh nghi?p gi?a c�c d?a phuong.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news"],
tagsearch_values: ["Tin VCCI", "Hợp tác quốc tế"],
tagsearch_values: ["Tin VCCI", "H?p t�c qu?c t?"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -466,15 +499,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
participation_fee: "Miễn phí",
location: "TP. H? Ch� Minh",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-08-a",
type: "text",
position: 1,
content:
"<p>Đại diện hai bên nhấn mạnh nhu cầu phát triển bền vững và hỗ trợ cộng đồng doanh nghiệp thích ứng với thay đổi thị trường toàn cầu.</p>",
"<p>�?i di?n hai b�n nh?n m?nh nhu c?u ph�t tri?n b?n v?ng v� h? tr? c?ng d?ng doanh nghi?p th�ch ?ng v?i thay d?i th? tru?ng to�n c?u.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -483,14 +516,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-09",
title: "Những điểm sáng trong bức tranh kinh tế, số liệu quý II và 6 tháng đầu năm 2026",
title: "Nh?ng di?m s�ng trong b?c tranh kinh t?, s? li?u qu� II v� 6 th�ng d?u nam 2026",
slug: "nhung-diem-sang-trong-buc-tranh-kinh-te-so-lieu-quy-ii-va-6-thang-dau-nam-2026",
summary:
"<p>Bản tin tổng hợp các tín hiệu phục hồi, tăng trưởng xuất khẩu và mức độ cải thiện niềm tin thị trường trong nhiều nhóm ngành.</p>",
"<p>B?n tin t?ng h?p c�c t�n hi?u ph?c h?i, tang tru?ng xu?t kh?u v� m?c d? c?i thi?n ni?m tin th? tru?ng trong nhi?u nh�m ng�nh.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news"],
tagsearch_values: ["Tin Kinh Tế", "Vĩ mô"],
tagsearch_values: ["Tin Kinh T?", "Vi m�"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -501,15 +534,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Hà Nội",
participation_fee: "Miễn phí",
location: "H� N?i",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-09-a",
type: "text",
position: 1,
content:
"<p>Dữ liệu cho thấy nhiều nhóm doanh nghiệp đang cải thiện năng lực đơn hàng và thích ứng tốt hơn với biến động chi phí.</p>",
"<p>D? li?u cho th?y nhi?u nh�m doanh nghi?p dang c?i thi?n nang l?c don h�ng v� th�ch ?ng t?t hon v?i bi?n d?ng chi ph�.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -518,14 +551,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-10",
title: "Tình hình kinh tế - vĩ mô Quý 1 năm 2026",
title: "T�nh h�nh kinh t? - vi m� Qu� 1 nam 2026",
slug: "tinh-hinh-kinh-te-vi-mo-quy-1-nam-2026",
summary:
"<p>Báo cáo nhanh về tăng trưởng, lạm phát, lãi suất và xu hướng đầu tư trong bối cảnh kinh tế quốc tế còn nhiều thay đổi.</p>",
"<p>B�o c�o nhanh v? tang tru?ng, l?m ph�t, l�i su?t v� xu hu?ng d?u tu trong b?i c?nh kinh t? qu?c t? c�n nhi?u thay d?i.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news"],
tagsearch_values: ["Tin Kinh Tế", "Vĩ mô"],
tagsearch_values: ["Tin Kinh T?", "Vi m�"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
......@@ -536,15 +569,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Hà Nội",
participation_fee: "Miễn phí",
location: "H� N?i",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-10-a",
type: "text",
position: 1,
content:
"<p>Bản tin cung cấp góc nhìn cô đọng về những chỉ số ảnh hưởng trực tiếp đến hoạt động sản xuất, thương mại và đầu tư.</p>",
"<p>B?n tin cung c?p g�c nh�n c� d?ng v? nh?ng ch? s? ?nh hu?ng tr?c ti?p d?n ho?t d?ng s?n xu?t, thuong m?i v� d?u tu.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -553,14 +586,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-11",
title: "Cẩm nang hướng dẫn đầu tư kinh doanh tại Việt Nam dành cho doanh nghiệp mới",
title: "C?m nang hu?ng d?n d?u tu kinh doanh t?i Vi?t Nam d�nh cho doanh nghi?p m?i",
slug: "cam-nang-huong-dan-dau-tu-kinh-doanh-tai-viet-nam-danh-cho-doanh-nghiep-moi",
summary:
"<p>Tài liệu tổng hợp các bước chuẩn bị hồ sơ, lựa chọn địa điểm và những lưu ý pháp lý ban đầu cho nhà đầu tư và doanh nghiệp.</p>",
"<p>T�i li?u t?ng h?p c�c bu?c chu?n b? h? so, l?a ch?n d?a di?m v� nh?ng luu � ph�p l� ban d?u cho nh� d?u tu v� doanh nghi?p.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news"],
tagsearch_values: ["Chuyên Đề", "Cẩm nang"],
tagsearch_values: ["Chuy�n �?", "C?m nang"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -571,15 +604,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Trực tuyến",
participation_fee: "Miễn phí",
location: "Tr?c tuy?n",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-11-a",
type: "text",
position: 1,
content:
"<p>Nội dung được biên tập theo hướng dễ áp dụng, giúp doanh nghiệp mới có thể tra cứu nhanh khi bắt đầu triển khai dự án.</p>",
"<p>N?i dung du?c bi�n t?p theo hu?ng d? �p d?ng, gi�p doanh nghi?p m?i c� th? tra c?u nhanh khi b?t d?u tri?n khai d? �n.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -588,14 +621,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-12",
title: "Khóa đào tạo: Quản trị Thuế và Pháp lý trong giao dịch",
title: "Kh�a d�o t?o: Qu?n tr? Thu? v� Ph�p l� trong giao d?ch",
slug: "khoa-dao-tao-quan-tri-thue-va-phap-ly-trong-giao-dich",
summary:
"<p>Chương trình cập nhật các điểm mới về quản trị thuế, hồ sơ giao dịch và kiểm soát rủi ro pháp lý trong doanh nghiệp.</p>",
"<p>Chuong tr�nh c?p nh?t c�c di?m m?i v? qu?n tr? thu?, h? so giao d?ch v� ki?m so�t r?i ro ph�p l� trong doanh nghi?p.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Đào tạo", "Sự kiện"],
tagsearch_values: ["��o t?o", "S? ki?n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -606,15 +639,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-11-18T08:30",
ended_at: "2026-11-18T16:30",
registration_deadline: "2026-11-15T17:00",
location: "TP. Hồ Chí Minh",
participation_fee: "800.000 VNĐ",
location: "TP. H? Ch� Minh",
participation_fee: "800.000 VN",
post_content: [
{
id: "section-admin-news-12-a",
type: "text",
position: 1,
content:
"<p>Khóa học dành cho đội ngũ quản lý, kế toán trưởng và chuyên viên pháp chế cần chuẩn hóa quy trình nội bộ.</p>",
"<p>Kh�a h?c d�nh cho d?i ngu qu?n l�, k? to�n tru?ng v� chuy�n vi�n ph�p ch? c?n chu?n h�a quy tr�nh n?i b?.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -623,14 +656,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-13",
title: "Sự kiện - Tập huấn NSDLĐ",
title: "S? ki?n - T?p hu?n NSDL�",
slug: "su-kien-tap-huan-nsdld",
summary:
"<p>Buổi tập huấn hướng dẫn người sử dụng lao động cập nhật các quy định thực thi và quy trình phối hợp với bộ phận nhân sự.</p>",
"<p>Bu?i t?p hu?n hu?ng d?n ngu?i s? d?ng lao d?ng c?p nh?t c�c quy d?nh th?c thi v� quy tr�nh ph?i h?p v?i b? ph?n nh�n s?.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Đào tạo", "Sự kiện"],
tagsearch_values: ["��o t?o", "S? ki?n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -641,15 +674,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-11-20T13:30",
ended_at: "2026-11-20T17:00",
registration_deadline: "2026-11-18T17:00",
location: "Hà Nội",
participation_fee: "Miễn phí",
location: "H� N?i",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-13-a",
type: "text",
position: 1,
content:
"<p>Nội dung tập huấn tập trung vào các tình huống thường gặp trong quá trình vận hành chính sách nhân sự và lao động.</p>",
"<p>N?i dung t?p hu?n t?p trung v�o c�c t�nh hu?ng thu?ng g?p trong qu� tr�nh v?n h�nh ch�nh s�ch nh�n s? v� lao d?ng.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -658,14 +691,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-14",
title: "Diễn đàn hội viên: Kết nối thị trường và chuỗi cung ứng",
title: "Di?n d�n h?i vi�n: K?t n?i th? tru?ng v� chu?i cung ?ng",
slug: "dien-dan-hoi-vien-ket-noi-thi-truong-va-chuoi-cung-ung",
summary:
"<p>Diễn đàn tạo không gian gặp gỡ giữa doanh nghiệp sản xuất, đơn vị phân phối và nhà cung cấp dịch vụ hỗ trợ thị trường.</p>",
"<p>Di?n d�n t?o kh�ng gian g?p g? gi?a doanh nghi?p s?n xu?t, don v? ph�n ph?i v� nh� cung c?p d?ch v? h? tr? th? tru?ng.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Sự kiện", "Hội viên"],
tagsearch_values: ["S? ki?n", "H?i vi�n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
......@@ -676,15 +709,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-11-24T08:00",
ended_at: "2026-11-24T12:00",
registration_deadline: "2026-11-22T17:00",
location: "Đà Nẵng",
participation_fee: "Miễn phí",
location: "�� N?ng",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-14-a",
type: "text",
position: 1,
content:
"<p>Chương trình hướng đến việc mở rộng cơ hội kết nối đối tác, chia sẻ nhu cầu thị trường và xây dựng chuỗi cung ứng linh hoạt hơn.</p>",
"<p>Chuong tr�nh hu?ng d?n vi?c m? r?ng co h?i k?t n?i d?i t�c, chia s? nhu c?u th? tru?ng v� x�y d?ng chu?i cung ?ng linh ho?t hon.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -693,14 +726,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-15",
title: "Chương trình kết nối doanh nghiệp hội viên ngành thực phẩm và bán lẻ",
title: "Chuong tr�nh k?t n?i doanh nghi?p h?i vi�n ng�nh th?c ph?m v� b�n l?",
slug: "chuong-trinh-ket-noi-doanh-nghiep-hoi-vien-nganh-thuc-pham-va-ban-le",
summary:
"<p>Buổi kết nối tạo không gian giới thiệu sản phẩm, chia sẻ nhu cầu mua hàng và ghép nối đối tác giữa doanh nghiệp sản xuất với hệ thống phân phối.</p>",
"<p>Bu?i k?t n?i t?o kh�ng gian gi?i thi?u s?n ph?m, chia s? nhu c?u mua h�ng v� gh�p n?i d?i t�c gi?a doanh nghi?p s?n xu?t v?i h? th?ng ph�n ph?i.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Sự kiện", "Hội viên"],
tagsearch_values: ["S? ki?n", "H?i vi�n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -711,15 +744,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-22T14:00",
ended_at: "2026-05-22T17:00",
registration_deadline: "2026-05-20T17:00",
location: "TP. Hồ Chí Minh",
participation_fee: "Miễn phí",
location: "TP. H? Ch� Minh",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-15-a",
type: "text",
position: 1,
content:
"<p>Chương trình ưu tiên các nhóm doanh nghiệp đang cần mở rộng hệ thống phân phối và tìm đối tác đồng hành tại khu vực phía Nam.</p>",
"<p>Chuong tr�nh uu ti�n c�c nh�m doanh nghi?p dang c?n m? r?ng h? th?ng ph�n ph?i v� t�m d?i t�c d?ng h�nh t?i khu v?c ph�a Nam.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -728,14 +761,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-16",
title: "Lớp đào tạo ngắn hạn: Kỹ năng xây dựng kế hoạch xúc tiến thương mại",
title: "L?p d�o t?o ng?n h?n: K? nang x�y d?ng k? ho?ch x�c ti?n thuong m?i",
slug: "lop-dao-tao-ngan-han-ky-nang-xay-dung-ke-hoach-xuc-tien-thuong-mai",
summary:
"<p>Khóa học hướng dẫn doanh nghiệp xác định mục tiêu, ngân sách và cách triển khai hoạt động xúc tiến thương mại theo từng giai đoạn.</p>",
"<p>Kh�a h?c hu?ng d?n doanh nghi?p x�c d?nh m?c ti�u, ng�n s�ch v� c�ch tri?n khai ho?t d?ng x�c ti?n thuong m?i theo t?ng giai do?n.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Đào tạo", "Sự kiện"],
tagsearch_values: ["��o t?o", "S? ki?n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -746,15 +779,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-26T08:30",
ended_at: "2026-05-26T11:30",
registration_deadline: "2026-05-24T17:00",
location: "Trực tuyến",
participation_fee: "350.000 VNĐ",
location: "Tr?c tuy?n",
participation_fee: "350.000 VN",
post_content: [
{
id: "section-admin-news-16-a",
type: "text",
position: 1,
content:
"<p>Nội dung tập trung vào cấu trúc kế hoạch, xây dựng đầu việc ưu tiên và lựa chọn kênh triển khai phù hợp với nguồn lực của doanh nghiệp.</p>",
"<p>N?i dung t?p trung v�o c?u tr�c k? ho?ch, x�y d?ng d?u vi?c uu ti�n v� l?a ch?n k�nh tri?n khai ph� h?p v?i ngu?n l?c c?a doanh nghi?p.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -763,14 +796,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-17",
title: "Công ty Western Coast Enterprise LTD cần thuê mua vật liệu xây dựng tại Việt Nam",
title: "C�ng ty Western Coast Enterprise LTD c?n thu� mua v?t li?u x�y d?ng t?i Vi?t Nam",
slug: "cong-ty-western-coast-enterprise-ltd-can-thue-mua-vat-lieu-xay-dung-tai-viet-nam",
summary:
"<p>Doanh nghiệp tìm kiếm đối tác cung ứng vật liệu xây dựng ổn định tại thị trường Việt Nam để phục vụ kế hoạch mở rộng chuỗi dự án trong khu vực.</p>",
"<p>Doanh nghi?p t�m ki?m d?i t�c cung ?ng v?t li?u x�y d?ng ?n d?nh t?i th? tru?ng Vi?t Nam d? ph?c v? k? ho?ch m? r?ng chu?i d? �n trong khu v?c.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-business-opportunity"],
tagsearch_values: ["Cơ hội kinh doanh", "Kết nối giao thương"],
tagsearch_values: ["Co h?i kinh doanh", "K?t n?i giao thuong"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -781,7 +814,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
location: "TP. H? Ch� Minh",
participation_fee: "",
post_content: [
{
......@@ -789,7 +822,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Nhu cầu tập trung vào nhóm vật liệu hoàn thiện, vật liệu nền móng và các nhà cung ứng có khả năng đáp ứng đơn hàng dài hạn.</p>",
"<p>Nhu c?u t?p trung v�o nh�m v?t li?u ho�n thi?n, v?t li?u n?n m�ng v� c�c nh� cung ?ng c� kh? nang d�p ?ng don h�ng d�i h?n.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -798,14 +831,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-18",
title: "VCCI-HCM kết nối các nhà đầu tư nước ngoài với doanh nghiệp Việt trong chuỗi cung ứng công nghiệp",
title: "VCCI-HCM k?t n?i c�c nh� d?u tu nu?c ngo�i v?i doanh nghi?p Vi?t trong chu?i cung ?ng c�ng nghi?p",
slug: "vcci-hcm-ket-noi-cac-nha-dau-tu-nuoc-ngoai-voi-doanh-nghiep-viet-trong-chuoi-cung-ung-cong-nghiep",
summary:
"<p>Chương trình giới thiệu danh mục nhu cầu hợp tác, tìm nhà cung ứng linh kiện và đối tác gia công cho nhóm doanh nghiệp FDI.</p>",
"<p>Chuong tr�nh gi?i thi?u danh m?c nhu c?u h?p t�c, t�m nh� cung ?ng linh ki?n v� d?i t�c gia c�ng cho nh�m doanh nghi?p FDI.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-business-opportunity"],
tagsearch_values: ["Cơ hội kinh doanh", "Hội viên"],
tagsearch_values: ["Co h?i kinh doanh", "H?i vi�n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -816,7 +849,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Bình Dương",
location: "B�nh Duong",
participation_fee: "",
post_content: [
{
......@@ -824,7 +857,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Hoạt động ưu tiên những doanh nghiệp có năng lực sản xuất ổn định, minh bạch hồ sơ chất lượng và sẵn sàng tham gia đánh giá nhà máy.</p>",
"<p>Ho?t d?ng uu ti�n nh?ng doanh nghi?p c� nang l?c s?n xu?t ?n d?nh, minh b?ch h? so ch?t lu?ng v� s?n s�ng tham gia d�nh gi� nh� m�y.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -833,14 +866,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-19",
title: "Doanh nghiệp logistics tìm đối tác phân phối và khai thác tuyến vận chuyển liên vùng",
title: "Doanh nghi?p logistics t�m d?i t�c ph�n ph?i v� khai th�c tuy?n v?n chuy?n li�n v�ng",
slug: "doanh-nghiep-logistics-tim-doi-tac-phan-phoi-va-khai-thac-tuyen-van-chuyen-lien-vung",
summary:
"<p>Thông tin mời hợp tác dành cho các doanh nghiệp có hệ thống kho bãi, đội xe và năng lực xử lý đơn hàng tại khu vực phía Nam.</p>",
"<p>Th�ng tin m?i h?p t�c d�nh cho c�c doanh nghi?p c� h? th?ng kho b�i, d?i xe v� nang l?c x? l� don h�ng t?i khu v?c ph�a Nam.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-business-opportunity"],
tagsearch_values: ["Cơ hội kinh doanh", "Logistics"],
tagsearch_values: ["Co h?i kinh doanh", "Logistics"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
......@@ -859,7 +892,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Nội dung hợp tác bao gồm phân phối nội địa, gom hàng xuất khẩu và phát triển thêm các điểm trung chuyển mới tại khu vực lân cận.</p>",
"<p>N?i dung h?p t�c bao g?m ph�n ph?i n?i d?a, gom h�ng xu?t kh?u v� ph�t tri?n th�m c�c di?m trung chuy?n m?i t?i khu v?c l�n c?n.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -868,14 +901,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-20",
title: "Những chính sách mới có hiệu lực từ ngày 01/10/2026",
title: "Nh?ng ch�nh s�ch m?i c� hi?u l?c t? ng�y 01/10/2026",
slug: "nhung-chinh-sach-moi-co-hieu-luc-tu-ngay-01-10-2026",
summary:
"<p>Tổng hợp nhanh các quy định mới liên quan đến thuế, lao động và thủ tục hành chính mà doanh nghiệp cần lưu ý trong kỳ áp dụng mới.</p>",
"<p>T?ng h?p nhanh c�c quy d?nh m?i li�n quan d?n thu?, lao d?ng v� th? t?c h�nh ch�nh m� doanh nghi?p c?n luu � trong k? �p d?ng m?i.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-policy-law"],
tagsearch_values: ["Chính sách & pháp luật", "Chính sách"],
tagsearch_values: ["Ch�nh s�ch & ph�p lu?t", "Ch�nh s�ch"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
......@@ -886,7 +919,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Hà Nội",
location: "H� N?i",
participation_fee: "",
post_content: [
{
......@@ -894,7 +927,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Bài viết hệ thống lại các mốc áp dụng, nhóm đối tượng chịu tác động và một số đầu việc doanh nghiệp nên chuẩn bị sớm.</p>",
"<p>B�i vi?t h? th?ng l?i c�c m?c �p d?ng, nh�m d?i tu?ng ch?u t�c d?ng v� m?t s? d?u vi?c doanh nghi?p n�n chu?n b? s?m.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -903,14 +936,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-21",
title: "Luật được sửa đổi: nhiều quy định mới có lợi cho doanh nghiệp",
title: "Lu?t du?c s?a d?i: nhi?u quy d?nh m?i c� l?i cho doanh nghi?p",
slug: "luat-duoc-sua-doi-nhieu-quy-dinh-moi-co-loi-cho-doanh-nghiep",
summary:
"<p>Nội dung cập nhật tập trung vào các điểm sửa đổi về điều kiện kinh doanh, thủ tục hồ sơ và cơ chế hỗ trợ doanh nghiệp nhỏ và vừa.</p>",
"<p>N?i dung c?p nh?t t?p trung v�o c�c di?m s?a d?i v? di?u ki?n kinh doanh, th? t?c h? so v� co ch? h? tr? doanh nghi?p nh? v� v?a.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-policy-law"],
tagsearch_values: ["Chính sách & pháp luật", "Pháp luật"],
tagsearch_values: ["Ch�nh s�ch & ph�p lu?t", "Ph�p lu?t"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -921,7 +954,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Hà Nội",
location: "H� N?i",
participation_fee: "",
post_content: [
{
......@@ -929,7 +962,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Các thay đổi đáng chú ý giúp rút ngắn thời gian xử lý thủ tục và mở rộng thêm một số cơ chế linh hoạt cho nhà đầu tư.</p>",
"<p>C�c thay d?i d�ng ch� � gi�p r�t ng?n th?i gian x? l� th? t?c v� m? r?ng th�m m?t s? co ch? linh ho?t cho nh� d?u tu.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -938,14 +971,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-22",
title: "Bảo vệ cổ đông: có được coi là sự kiện bất khả kháng đối với doanh nghiệp",
title: "B?o v? c? d�ng: c� du?c coi l� s? ki?n b?t kh? kh�ng d?i v?i doanh nghi?p",
slug: "bao-ve-co-dong-co-duoc-coi-la-su-kien-bat-kha-khang-doi-voi-doanh-nghiep",
summary:
"<p>Phân tích tình huống pháp lý thường gặp trong quản trị doanh nghiệp, trách nhiệm công bố thông tin và cách xác định rủi ro phát sinh.</p>",
"<p>Ph�n t�ch t�nh hu?ng ph�p l� thu?ng g?p trong qu?n tr? doanh nghi?p, tr�ch nhi?m c�ng b? th�ng tin v� c�ch x�c d?nh r?i ro ph�t sinh.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-policy-law"],
tagsearch_values: ["Chính sách & pháp luật", "Pháp luật"],
tagsearch_values: ["Ch�nh s�ch & ph�p lu?t", "Ph�p lu?t"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -956,7 +989,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
location: "TP. H? Ch� Minh",
participation_fee: "",
post_content: [
{
......@@ -964,7 +997,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Bài viết đưa ra góc nhìn thực tiễn và khuyến nghị bước chuẩn bị hồ sơ nội bộ khi phát sinh tranh chấp liên quan đến quyền cổ đông.</p>",
"<p>B�i vi?t dua ra g�c nh�n th?c ti?n v� khuy?n ngh? bu?c chu?n b? h? so n?i b? khi ph�t sinh tranh ch?p li�n quan d?n quy?n c? d�ng.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -973,14 +1006,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-23",
title: "Kết nối hội viên ngành xây dựng và vật liệu tại khu vực phía Nam",
title: "K?t n?i h?i vi�n ng�nh x�y d?ng v� v?t li?u t?i khu v?c ph�a Nam",
slug: "ket-noi-hoi-vien-nganh-xay-dung-va-vat-lieu-tai-khu-vuc-phia-nam",
summary:
"<p>Hoạt động kết nối tập trung vào nhóm doanh nghiệp sản xuất vật liệu, thi công công trình và đơn vị tư vấn đang cần mở rộng mạng lưới hợp tác.</p>",
"<p>Ho?t d?ng k?t n?i t?p trung v�o nh�m doanh nghi?p s?n xu?t v?t li?u, thi c�ng c�ng tr�nh v� don v? tu v?n dang c?n m? r?ng m?ng lu?i h?p t�c.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-member-connection"],
tagsearch_values: ["Kết nối hội viên", "Hội viên"],
tagsearch_values: ["K?t n?i h?i vi�n", "H?i vi�n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -991,7 +1024,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
location: "TP. H? Ch� Minh",
participation_fee: "",
post_content: [
{
......@@ -999,7 +1032,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Chương trình giới thiệu nhu cầu hợp tác, nhu cầu nhà cung ứng và các cơ hội triển khai dự án chung trong giai đoạn tới.</p>",
"<p>Chuong tr�nh gi?i thi?u nhu c?u h?p t�c, nhu c?u nh� cung ?ng v� c�c co h?i tri?n khai d? �n chung trong giai do?n t?i.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -1008,14 +1041,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-24",
title: "Không gian gặp gỡ hội viên mới trong mạng lưới doanh nghiệp dịch vụ",
title: "Kh�ng gian g?p g? h?i vi�n m?i trong m?ng lu?i doanh nghi?p d?ch v?",
slug: "khong-gian-gap-go-hoi-vien-moi-trong-mang-luoi-doanh-nghiep-dich-vu",
summary:
"<p>Buổi networking quy tụ đại diện doanh nghiệp dịch vụ, thương mại và đơn vị tư vấn cùng chia sẻ nhu cầu kết nối khách hàng.</p>",
"<p>Bu?i networking quy t? d?i di?n doanh nghi?p d?ch v?, thuong m?i v� don v? tu v?n c�ng chia s? nhu c?u k?t n?i kh�ch h�ng.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-member-connection"],
tagsearch_values: ["Kết nối hội viên", "Networking"],
tagsearch_values: ["K?t n?i h?i vi�n", "Networking"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
......@@ -1026,7 +1059,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
location: "TP. H? Ch� Minh",
participation_fee: "",
post_content: [
{
......@@ -1034,7 +1067,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Nội dung chú trọng vào việc tạo điểm chạm ban đầu giữa các hội viên mới với cộng đồng doanh nghiệp hiện có của VCCI-HCM.</p>",
"<p>N?i dung ch� tr?ng v�o vi?c t?o di?m ch?m ban d?u gi?a c�c h?i vi�n m?i v?i c?ng d?ng doanh nghi?p hi?n c� c?a VCCI-HCM.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -1043,14 +1076,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-25",
title: "Chương trình chia sẻ cơ hội hợp tác giữa hội viên công nghệ và doanh nghiệp truyền thống",
title: "Chuong tr�nh chia s? co h?i h?p t�c gi?a h?i vi�n c�ng ngh? v� doanh nghi?p truy?n th?ng",
slug: "chuong-trinh-chia-se-co-hoi-hop-tac-giua-hoi-vien-cong-nghe-va-doanh-nghiep-truyen-thong",
summary:
"<p>Chuỗi hoạt động giúp doanh nghiệp công nghệ giới thiệu giải pháp số và tìm đối tác ứng dụng trong sản xuất, thương mại và dịch vụ.</p>",
"<p>Chu?i ho?t d?ng gi�p doanh nghi?p c�ng ngh? gi?i thi?u gi?i ph�p s? v� t�m d?i t�c ?ng d?ng trong s?n xu?t, thuong m?i v� d?ch v?.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-member-connection"],
tagsearch_values: ["Kết nối hội viên", "Chuyển đổi số"],
tagsearch_values: ["K?t n?i h?i vi�n", "Chuy?n d?i s?"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -1061,7 +1094,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "",
ended_at: "",
registration_deadline: "",
location: "Trực tuyến",
location: "Tr?c tuy?n",
participation_fee: "",
post_content: [
{
......@@ -1069,7 +1102,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text",
position: 1,
content:
"<p>Chương trình mở ra nhiều phiên giới thiệu nhanh, matching nhu cầu và trao đổi mô hình triển khai phù hợp theo từng nhóm ngành.</p>",
"<p>Chuong tr�nh m? ra nhi?u phi�n gi?i thi?u nhanh, matching nhu c?u v� trao d?i m� h�nh tri?n khai ph� h?p theo t?ng nh�m ng�nh.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -1078,14 +1111,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-26",
title: "Phiên kết nối nhà mua hàng quốc tế với doanh nghiệp hội viên ngành gỗ",
title: "Phi�n k?t n?i nh� mua h�ng qu?c t? v?i doanh nghi?p h?i vi�n ng�nh g?",
slug: "phien-ket-noi-nha-mua-hang-quoc-te-voi-doanh-nghiep-hoi-vien-nganh-go",
summary:
"<p>Chương trình tập trung vào nhu cầu sourcing sản phẩm nội thất, vật liệu hoàn thiện và nhóm doanh nghiệp có năng lực xuất khẩu tại khu vực phía Nam.</p>",
"<p>Chuong tr�nh t?p trung v�o nhu c?u sourcing s?n ph?m n?i th?t, v?t li?u ho�n thi?n v� nh�m doanh nghi?p c� nang l?c xu?t kh?u t?i khu v?c ph�a Nam.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Sự kiện", "Hội viên"],
tagsearch_values: ["S? ki?n", "H?i vi�n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -1096,15 +1129,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-29T08:30",
ended_at: "2026-05-29T11:30",
registration_deadline: "2026-05-27T17:00",
location: "TP. Hồ Chí Minh",
participation_fee: "Miễn phí",
location: "TP. H? Ch� Minh",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-26-a",
type: "text",
position: 1,
content:
"<p>Hoạt động ưu tiên các doanh nghiệp có bộ hồ sơ năng lực xuất khẩu rõ ràng và sản phẩm phù hợp với nhóm nhà mua hàng đang mở rộng nguồn cung tại Việt Nam.</p>",
"<p>Ho?t d?ng uu ti�n c�c doanh nghi?p c� b? h? so nang l?c xu?t kh?u r� r�ng v� s?n ph?m ph� h?p v?i nh�m nh� mua h�ng dang m? r?ng ngu?n cung t?i Vi?t Nam.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -1113,14 +1146,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-27",
title: "Hội thảo chuyên đề logistics lạnh cho doanh nghiệp thực phẩm và nông sản",
title: "H?i th?o chuy�n d? logistics l?nh cho doanh nghi?p th?c ph?m v� n�ng s?n",
slug: "hoi-thao-chuyen-de-logistics-lanh-cho-doanh-nghiep-thuc-pham-va-nong-san",
summary:
"<p>Chuỗi chia sẻ cập nhật xu hướng bảo quản lạnh, tối ưu chi phí vận chuyển và tiêu chuẩn chất lượng trong chuỗi cung ứng thực phẩm.</p>",
"<p>Chu?i chia s? c?p nh?t xu hu?ng b?o qu?n l?nh, t?i uu chi ph� v?n chuy?n v� ti�u chu?n ch?t lu?ng trong chu?i cung ?ng th?c ph?m.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Sự kiện", "Chuyên đề"],
tagsearch_values: ["S? ki?n", "Chuy�n d?"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
......@@ -1131,15 +1164,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-30T13:30",
ended_at: "2026-05-30T16:30",
registration_deadline: "2026-05-28T17:00",
location: "Cần Thơ",
participation_fee: "Miễn phí",
location: "C?n Tho",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-27-a",
type: "text",
position: 1,
content:
"<p>Phiên thảo luận kết nối doanh nghiệp sản xuất với đơn vị kho vận, chuỗi bán lẻ và nhà cung cấp giải pháp kiểm soát nhiệt độ trong bảo quản.</p>",
"<p>Phi�n th?o lu?n k?t n?i doanh nghi?p s?n xu?t v?i don v? kho v?n, chu?i b�n l? v� nh� cung c?p gi?i ph�p ki?m so�t nhi?t d? trong b?o qu?n.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -1148,14 +1181,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-28",
title: "Khóa đào tạo thực hành xây dựng hồ sơ năng lực số cho doanh nghiệp hội viên",
title: "Kh�a d�o t?o th?c h�nh x�y d?ng h? so nang l?c s? cho doanh nghi?p h?i vi�n",
slug: "khoa-dao-tao-thuc-hanh-xay-dung-ho-so-nang-luc-so-cho-doanh-nghiep-hoi-vien",
summary:
"<p>Khóa học hướng dẫn doanh nghiệp chuẩn hóa profile giới thiệu, tài liệu bán hàng và công cụ trình bày năng lực trên môi trường số.</p>",
"<p>Kh�a h?c hu?ng d?n doanh nghi?p chu?n h�a profile gi?i thi?u, t�i li?u b�n h�ng v� c�ng c? tr�nh b�y nang l?c tr�n m�i tru?ng s?.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Đào tạo", "Hội viên"],
tagsearch_values: ["��o t?o", "H?i vi�n"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
......@@ -1166,15 +1199,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-06-03T08:30",
ended_at: "2026-06-03T11:30",
registration_deadline: "2026-06-01T17:00",
location: "Trực tuyến",
participation_fee: "300.000 VNĐ",
location: "Tr?c tuy?n",
participation_fee: "300.000 VN",
post_content: [
{
id: "section-admin-news-28-a",
type: "text",
position: 1,
content:
"<p>Nội dung tập trung vào các phần cốt lõi của bộ hồ sơ giới thiệu doanh nghiệp, hình ảnh minh họa và cách trình bày thông tin súc tích để chào đối tác.</p>",
"<p>N?i dung t?p trung v�o c�c ph?n c?t l�i c?a b? h? so gi?i thi?u doanh nghi?p, h�nh ?nh minh h?a v� c�ch tr�nh b�y th�ng tin s�c t�ch d? ch�o d?i t�c.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -1183,14 +1216,14 @@ const newsSeed: AdminNewsItem[] = [
},
{
id: "admin-news-29",
title: "Diễn đàn kết nối tài chính xanh cho doanh nghiệp sản xuất và xuất khẩu",
title: "Di?n d�n k?t n?i t�i ch�nh xanh cho doanh nghi?p s?n xu?t v� xu?t kh?u",
slug: "dien-dan-ket-noi-tai-chinh-xanh-cho-doanh-nghiep-san-xuat-va-xuat-khau",
summary:
"<p>Chương trình cập nhật các nguồn vốn xanh, tiêu chí đánh giá dự án và giải pháp chuẩn bị hồ sơ tiếp cận tài chính cho doanh nghiệp.</p>",
"<p>Chuong tr�nh c?p nh?t c�c ngu?n v?n xanh, ti�u ch� d�nh gi� d? �n v� gi?i ph�p chu?n b? h? so ti?p c?n t�i ch�nh cho doanh nghi?p.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Sự kiện", "Tài chính xanh"],
tagsearch_values: ["S? ki?n", "T�i ch�nh xanh"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
......@@ -1201,15 +1234,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-06-05T14:00",
ended_at: "2026-06-05T17:00",
registration_deadline: "2026-06-03T17:00",
location: "Hà Nội",
participation_fee: "Miễn phí",
location: "H� N?i",
participation_fee: "Mi?n ph�",
post_content: [
{
id: "section-admin-news-29-a",
type: "text",
position: 1,
content:
"<p>Diễn đàn tạo nhịp kết nối giữa doanh nghiệp, ngân hàng và đơn vị tư vấn để trao đổi về điều kiện tiếp cận các chương trình tài chính xanh.</p>",
"<p>Di?n d�n t?o nh?p k?t n?i gi?a doanh nghi?p, ng�n h�ng v� don v? tu v?n d? trao d?i v? di?u ki?n ti?p c?n c�c chuong tr�nh t�i ch�nh xanh.</p>",
image_columns: 2,
image_rows: 2,
images: [],
......@@ -1222,8 +1255,8 @@ export function slugifyAdminNews(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/d/g, "d")
.replace(//g, "D")
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
......@@ -1308,12 +1341,14 @@ export function cloneAdminNewsFormValues(item?: AdminNewsItem | null): AdminNews
}
export function normalizeAdminMediaItems(items: AdminMediaItem[]) {
return [...items].sort((left, right) => {
return (
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime() ||
right.name.localeCompare(left.name, "vi")
);
});
return items
.map((item) => normalizeSeedMediaLabels(item))
.sort((left, right) => {
return (
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime() ||
right.name.localeCompare(left.name, "vi")
);
});
}
export function normalizeAdminNewsItems(items: AdminNewsItem[]) {
......@@ -1354,7 +1389,18 @@ export function readAdminMediaItems() {
return getAdminMediaSeed();
}
return normalizeAdminMediaItems(parsed);
const normalizedItems = normalizeAdminMediaItems(parsed);
const hasNormalizedChanges =
JSON.stringify(parsed) !== JSON.stringify(normalizedItems);
if (hasNormalizedChanges) {
window.localStorage.setItem(
ADMIN_MEDIA_STORAGE_KEY,
JSON.stringify(normalizedItems),
);
}
return normalizedItems;
} catch {
return getAdminMediaSeed();
}
......
......@@ -257,7 +257,7 @@ export const categoryFallbackRows: Category[] = [
id: "142c9525-b206-4b87-8978-4b7048a46a3b",
name: "Trang chủ",
slug: "trang-chu",
url: "/trang-chu",
url: "/",
sort_order: 1,
created_at: "2026-05-14T04:54:26.127Z",
created_by: null,
......
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
export interface AuthenticatedAdminUser {
id: string;
email: string;
username: string;
first_name: string | null;
last_name: string | null;
roles: string[];
permissions: string[];
status: string | null;
last_login_at: string | null;
}
export interface AuthenticatedAdminSession {
id: string | null;
expires_at: string | null;
refresh_expires_at: string | null;
}
export interface AuthSessionPayload {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: AuthenticatedAdminUser | null;
session: AuthenticatedAdminSession | null;
}
export interface AuthRefreshPayload {
accessToken: string;
expiresIn: number;
refreshToken?: string | null;
session?: AuthenticatedAdminSession | null;
}
export interface AuthStoreStateType {
// States
appIsLoggedIn: boolean
appAccessToken: string | null
appAccessTokenExpired: number | null
appRefreshToken: string | null
appUserRemember: {
username: string
password: string
remember: boolean
} | null
_hasHydrated: boolean
// Actions
setHasHydrated: (state: AuthStoreStateType) => void
setAppIsLoggedIn: (isLoggedIn: boolean) => void
setAppToken: (accessToken: string, accessTokenExpired: number, refreshToken?: string) => void
removeAppToken: () => void
setAppUserRemember: (username: string, password: string, remember: boolean) => void
resetStore: () => void
}
// Define store
const useAuthStore = create<AuthStoreStateType>()(
devtools(
persist(
(set, get) => ({
// States
appIsLoggedIn: false,
appAccessToken: null,
appAccessTokenExpired: null,
appRefreshToken: null,
appUserRemember: null,
// Methods
setAppIsLoggedIn: (isLoggedIn: boolean) => set(() => ({ appIsLoggedIn: isLoggedIn })),
appIsLoggedIn: boolean;
appAccessToken: string | null;
appAccessTokenExpired: number | null;
appRefreshToken: string | null;
appSession: AuthenticatedAdminSession | null;
appUser: AuthenticatedAdminUser | null;
appIsRefreshing: boolean;
appSessionExpiredNotified: boolean;
appUserRemember: {
username: string;
password: string;
remember: boolean;
} | null;
_hasHydrated: boolean;
setHasHydrated: (state: AuthStoreStateType) => void;
setAppIsLoggedIn: (isLoggedIn: boolean) => void;
setAuthSession: (payload: AuthSessionPayload) => void;
updateAccessToken: (payload: AuthRefreshPayload) => void;
setAppUser: (user: AuthenticatedAdminUser | null) => void;
setAppToken: (accessToken: string, accessTokenExpired: number, refreshToken?: string) => void;
setAppRefreshing: (isRefreshing: boolean) => void;
markSessionExpiredNotified: (notified: boolean) => void;
removeAppToken: () => void;
setAppUserRemember: (username: string, password: string, remember: boolean) => void;
resetStore: () => void;
}
const getAccessTokenExpiredAt = (expiresIn: number) =>
Date.now() + Math.max(expiresIn - 300, 0) * 1000;
const baseState = {
appIsLoggedIn: false,
appAccessToken: null,
appAccessTokenExpired: null,
appRefreshToken: null,
appSession: null,
appUser: null,
appIsRefreshing: false,
appSessionExpiredNotified: false,
appUserRemember: null,
_hasHydrated: false,
};
const clearSessionState = {
appIsLoggedIn: false,
appAccessToken: null,
appAccessTokenExpired: null,
appRefreshToken: null,
appSession: null,
appUser: null,
appIsRefreshing: false,
appSessionExpiredNotified: false,
};
const useAuthStore = create<AuthStoreStateType>()(
devtools(
persist(
(set, get) => ({
...baseState,
setHasHydrated: (state: AuthStoreStateType) =>
set(() => ({
_hasHydrated: state != undefined,
})),
setAppIsLoggedIn: (isLoggedIn: boolean) =>
set(() => ({
appIsLoggedIn: isLoggedIn,
})),
setAuthSession: ({ accessToken, refreshToken, expiresIn, user, session }) =>
set(() => ({
appIsLoggedIn: true,
appAccessToken: accessToken,
appAccessTokenExpired: getAccessTokenExpiredAt(expiresIn),
appRefreshToken: refreshToken,
appSession: session,
appUser: user,
appIsRefreshing: false,
appSessionExpiredNotified: false,
})),
updateAccessToken: ({ accessToken, expiresIn, refreshToken, session }) =>
set(() => ({
appIsLoggedIn: true,
appAccessToken: accessToken,
appAccessTokenExpired: getAccessTokenExpiredAt(expiresIn),
appRefreshToken: refreshToken ?? get().appRefreshToken,
appSession: session ?? get().appSession,
appIsRefreshing: false,
})),
setAppUser: (user: AuthenticatedAdminUser | null) =>
set(() => ({
appUser: user,
})),
setAppToken: (accessToken: string, accessTokenExpired: number, refreshToken?: string) =>
set(() => ({
appIsLoggedIn: true,
appAccessToken: accessToken,
appAccessTokenExpired: Date.now() + Math.max(accessTokenExpired - 300, 0) * 1000,
appRefreshToken: refreshToken ?? get().appRefreshToken
appAccessTokenExpired: getAccessTokenExpiredAt(accessTokenExpired),
appRefreshToken: refreshToken ?? get().appRefreshToken,
appIsRefreshing: false,
})),
setAppRefreshing: (isRefreshing: boolean) =>
set(() => ({
appIsRefreshing: isRefreshing,
})),
markSessionExpiredNotified: (notified: boolean) =>
set(() => ({
appSessionExpiredNotified: notified,
})),
removeAppToken: () => {
set(() => ({
appIsLoggedIn: false,
appAccessToken: null,
appAccessTokenExpired: null,
appRefreshToken: null
}))
},
setAppUserRemember: (username, password, remember) =>
set(() => ({
appUserRemember: {
username,
password,
remember
}
})),
...clearSessionState,
appUserRemember: get().appUserRemember,
_hasHydrated: get()._hasHydrated,
}));
},
setAppUserRemember: (username, password, remember) =>
set(() => ({
appUserRemember: {
username,
password,
remember,
},
})),
resetStore: () => {
// Clear in-memory state
const rememberedUser = get().appUserRemember;
set(() => ({
appIsLoggedIn: false,
appAccessToken: null,
appAccessTokenExpired: null,
appRefreshToken: null,
appUserRemember: null,
_hasHydrated: true
}))
// Remove persisted storage
try {
localStorage.removeItem('app-auth-storage')
} catch {
// ignore
}
},
_hasHydrated: false,
setHasHydrated: (state: AuthStoreStateType) =>
set(() => ({
_hasHydrated: state != undefined
}))
}),
{
name: 'app-auth-storage',
onRehydrateStorage: () => {
return (state: AuthStoreStateType | undefined, error: unknown) => {
if (error || state == undefined) return
state.setHasHydrated(state)
return
}
}
}
)
)
)
export default useAuthStore
...clearSessionState,
appUserRemember: rememberedUser,
_hasHydrated: true,
}));
try {
localStorage.removeItem("app-auth-storage");
} catch {
// ignore
}
},
}),
{
name: "app-auth-storage",
partialize: (state) => ({
appIsLoggedIn: state.appIsLoggedIn,
appAccessToken: state.appAccessToken,
appAccessTokenExpired: state.appAccessTokenExpired,
appRefreshToken: state.appRefreshToken,
appSession: state.appSession,
appUser: state.appUser,
appSessionExpiredNotified: state.appSessionExpiredNotified,
appUserRemember: state.appUserRemember,
}),
onRehydrateStorage: () => {
return (state: AuthStoreStateType | undefined, error: unknown) => {
if (error || state == undefined) return;
state.setHasHydrated(state);
};
},
},
),
),
);
export default useAuthStore;
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