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 // Core
import { AxiosError, isAxiosError } from 'axios' import { AxiosError, isAxiosError } from 'axios'
import { QueryClient } from '@tanstack/react-query' import { QueryClient } from '@tanstack/react-query'
// App // App
// import router from '@/router' // import router from '@/router'
import useAuthStore from '@/store/useAuthStore' import { handleAdminUnauthorized } from '@/lib/auth/admin-auth'
// import useProfileStore from '@stores/profile' // import useProfileStore from '@stores/profile'
import { QueryData } from '@/lib/types/base-api' import { QueryData } from '@/lib/types/base-api'
// import { BASE_PATHS } from '@/constants/path' // import { BASE_PATHS } from '@/constants/path'
// Constants // Constants
...@@ -40,16 +40,15 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => { ...@@ -40,16 +40,15 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
} }
// Handle un authorization error // Handle un authorization error
const handleUnAuthorizationError = () => { const handleUnAuthorizationError = () => {
useAuthStore.getState().resetStore() void handleAdminUnauthorized()
// useProfileStore.getState().resetStore() // useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({ // const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn // path: BASE_PATHS.authSignIn
// }) // })
// router.navigate('') // router.navigate('')
window.location.href = process ? '/' : '/admin' }
}
// Handle delay value // Handle delay value
const handleDelayRetry = (failureCount: number) => failureCount * 1000 + Math.random() * 1000 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 createAxiosInstance = () => {
const instance = Axios.create({ const instance = Axios.create({
...@@ -6,11 +14,17 @@ const createAxiosInstance = () => { ...@@ -6,11 +14,17 @@ const createAxiosInstance = () => {
withCredentials: true, withCredentials: true,
}); });
instance.interceptors.request.use((config) => { instance.interceptors.request.use(async (config) => {
const token = getPersistedAccessToken(); if (shouldSkipAuthHandling(config.url)) {
return config;
}
const token = await ensureValidAdminAccessToken().catch(() => null);
if (token && !config.headers.Authorization) { if (token) {
config.headers.Authorization = `Bearer ${token}`; const headers = AxiosHeaders.from(config.headers);
headers.set("Authorization", `Bearer ${token}`);
config.headers = headers;
} }
return config; return config;
...@@ -18,7 +32,36 @@ const createAxiosInstance = () => { ...@@ -18,7 +32,36 @@ const createAxiosInstance = () => {
instance.interceptors.response.use( instance.interceptors.response.use(
async (response) => response, 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; return instance;
...@@ -26,23 +69,9 @@ const createAxiosInstance = () => { ...@@ -26,23 +69,9 @@ const createAxiosInstance = () => {
const AXIOS_INSTANCE = createAxiosInstance(); const AXIOS_INSTANCE = createAxiosInstance();
const getPersistedAccessToken = () => { const shouldSkipAuthHandling = (url?: string | null) => {
if (typeof window === "undefined") return null; if (!url) return false;
return /\/auth\/(login|refresh|logout)(\?|$)/.test(url);
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 convertHeaders = (headers?: HeadersInit): Record<string, string> | undefined => { const convertHeaders = (headers?: HeadersInit): Record<string, string> | undefined => {
......
'use client'; 'use client';
import { import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import Link from "next/link"; 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() { function BusinessOpportunities() {
const { businessPosts, categoryLinks, categoryNames } = useHomePosts();
const businessItems = businessPosts;
const [featuredItem, ...listItems] = businessItems; const [featuredItem, ...listItems] = businessItems;
const listSlots = Array.from({ length: 3 }, (_, index) => listItems[index] ?? null);
if (!featuredItem) return null; const sectionLink =
categoryLinks.get(categoryNames.coHoiKinhDoanh.toLowerCase()) ??
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh";
return ( return (
<section className="flex-1"> <section className="flex-1">
...@@ -42,7 +25,7 @@ function BusinessOpportunities() { ...@@ -42,7 +25,7 @@ function BusinessOpportunities() {
</div> </div>
<Link <Link
href="/xuc-tien-thuong-mai/co-hoi/" href={sectionLink}
className="text-[#24469c] transition-colors hover:text-[#1b55a1]" className="text-[#24469c] transition-colors hover:text-[#1b55a1]"
> >
<ChevronRight className="h-5 w-5" /> <ChevronRight className="h-5 w-5" />
...@@ -50,32 +33,56 @@ function BusinessOpportunities() { ...@@ -50,32 +33,56 @@ function BusinessOpportunities() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Link {featuredItem ? (
href="/xuc-tien-thuong-mai/co-hoi/" <Link
className="block rounded-[18px] bg-[#f5f7fb] px-4 py-3.5 transition-colors hover:bg-[#eef3fb]" 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 className="line-clamp-2 text-[16px] font-bold leading-[1.45] text-[#264798] md:text-[17px]">
</h3> {featuredItem.title}
<p className="mt-2 text-[13px] text-[#9aa8c1]">{formatPublishDate(featuredItem)}</p> </h3>
</Link> <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"> <div className="space-y-2.5">
{listItems.slice(0, 3).map((item) => ( {listSlots.map((item, index) =>
<Link item ? (
key={item.id} <Link
href="/xuc-tien-thuong-mai/co-hoi/" key={item.id}
className="flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]" 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"> <span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<h4 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798]"> <div className="min-w-0">
{item.title} <h4 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798]">
</h4> {item.title}
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">{formatPublishDate(item)}</p> </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> </div>
</Link> ),
))} )}
</div> </div>
</div> </div>
</section> </section>
......
'use client'; 'use client';
import { import { useHomePosts, type HomePostItem } from "@/app/(main)/(home)/lib/use-home-posts";
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { addMonths, format, getDay, startOfMonth, subMonths } from "date-fns"; import { addMonths, format, getDay, startOfMonth, subMonths } from "date-fns";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
...@@ -11,41 +8,32 @@ import { useMemo, useState } from "react"; ...@@ -11,41 +8,32 @@ import { useMemo, useState } from "react";
const weekDays = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"]; 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() { function EventsCalendar() {
const firstEventDate = eventItems[0]?.started_at const { eventPosts } = useHomePosts();
? new Date(eventItems[0].started_at)
const firstEventDate = eventPosts[0]?.startedAt
? new Date(eventPosts[0].startedAt)
: new Date("2026-11-01T00:00:00"); : new Date("2026-11-01T00:00:00");
const [currentMonth, setCurrentMonth] = useState( const [currentMonth, setCurrentMonth] = useState(
new Date(firstEventDate.getFullYear(), firstEventDate.getMonth(), 1), 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( const monthEvents = useMemo(
() => () =>
eventItems.filter((item) => { eventPosts.filter((item) => {
const date = new Date(item.started_at); const date = new Date(item.startedAt);
return ( return (
date.getMonth() === currentMonth.getMonth() && date.getMonth() === currentMonth.getMonth() &&
date.getFullYear() === currentMonth.getFullYear() date.getFullYear() === currentMonth.getFullYear()
); );
}), }),
[currentMonth], [currentMonth, eventPosts],
); );
const days = useMemo(() => { const days = useMemo(() => {
...@@ -62,10 +50,10 @@ function EventsCalendar() { ...@@ -62,10 +50,10 @@ function EventsCalendar() {
}, [currentMonth]); }, [currentMonth]);
const eventMap = useMemo(() => { const eventMap = useMemo(() => {
const map = new Map<string, AdminNewsItem[]>(); const map = new Map<string, HomePostItem[]>();
monthEvents.forEach((item) => { 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) ?? []; const existing = map.get(key) ?? [];
existing.push(item); existing.push(item);
map.set(key, existing); map.set(key, existing);
......
'use client'; 'use client';
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import { import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Link from "next/link"; 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() { function Events() {
const { eventPosts, categoryLinks, categoryNames } = useHomePosts();
const eventItems = eventPosts;
const [featuredEvent, ...sideEvents] = eventItems; const [featuredEvent, ...sideEvents] = eventItems;
const sideSlots = Array.from({ length: 4 }, (_, index) => sideEvents[index] ?? null);
if (!featuredEvent) return null; const eventsLink =
categoryLinks.get(categoryNames.suKien.toLowerCase()) ?? "/hoat-dong/su-kien";
return ( 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"> <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() { ...@@ -41,7 +24,7 @@ function Events() {
</div> </div>
<Link <Link
href="/hoat-dong/su-kien" href={eventsLink}
className="pt-1.5 text-sm font-semibold text-[#ffd34f] transition-colors hover:text-white" className="pt-1.5 text-sm font-semibold text-[#ffd34f] transition-colors hover:text-white"
> >
Xem sự kiện Xem sự kiện
...@@ -49,53 +32,84 @@ function Events() { ...@@ -49,53 +32,84 @@ function Events() {
</div> </div>
<div className="grid items-stretch gap-3 xl:grid-cols-[minmax(0,1.02fr)_minmax(270px,0.98fr)]"> <div className="grid items-stretch gap-3 xl:grid-cols-[minmax(0,1.02fr)_minmax(270px,0.98fr)]">
<Link {featuredEvent ? (
href="/hoat-dong/su-kien" <Link
className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]" 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 <div className="h-[220px] overflow-hidden md:h-[235px] xl:h-[248px]">
src={featuredEvent.thumbnail?.url ?? "/thumbnail.png"} <ImageNext
alt={featuredEvent.thumbnail?.alt || featuredEvent.title} src={featuredEvent.thumbnail?.url ?? "/thumbnail.png"}
width={720} alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
height={520} width={720}
className="h-full w-full object-cover" height={520}
/> className="h-full w-full object-cover"
</div> />
</div>
<div className="p-3 pt-2.5"> <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]"> <h3 className="line-clamp-2 text-[16px] font-extrabold uppercase leading-[1.28] text-[#22459b] md:text-[18px]">
{featuredEvent.title} {featuredEvent.title}
</h3> </h3>
<p className="mt-1.5 text-[13px] text-[#90a0bd]">{formatEventDate(featuredEvent)}</p> <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> </div>
</Link> )}
<div className="flex h-full flex-col gap-3"> <div className="flex h-full flex-col gap-3">
{sideEvents.slice(0, 4).map((item) => ( {sideSlots.map((item, index) =>
<Link item ? (
key={item.id} <Link
href="/hoat-dong/su-kien" key={item.id}
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" 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 <div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[12px]">
src={item.thumbnail?.url ?? "/thumbnail.png"} <ImageNext
alt={item.thumbnail?.alt || item.title} src={item.thumbnail?.url ?? "/thumbnail.png"}
width={160} alt={item.thumbnail?.alt || item.title}
height={160} width={160}
className="h-full w-full object-cover" height={160}
/> className="h-full w-full object-cover"
</div> />
</div>
<div className="min-w-0"> <div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] font-semibold leading-[1.35] text-white"> <h4 className="line-clamp-2 text-[15px] font-semibold leading-[1.35] text-white">
{item.title} {item.title}
</h4> </h4>
<p className="mt-1 text-[12px] text-white/78">{formatEventDate(item)}</p> <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> </div>
</Link> ),
))} )}
</div> </div>
</div> </div>
</div> </div>
......
'use client'; 'use client';
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import { import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { getHeaderCategorySeed } from "@/mockdata/header-config";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronRight, Mail, Phone } from "lucide-react"; import { ChevronRight, Mail, Phone } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
const FALLBACK_CATEGORY_LINK = "/hoat-dong/tin-tuc"; 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() { function FeaturedNews() {
const { featuredPosts, categoryNames, categoryLinks } = useHomePosts();
const featuredNewsItems = featuredPosts.slice(0, 3);
const [primaryItem, ...secondaryItems] = featuredNewsItems; const [primaryItem, ...secondaryItems] = featuredNewsItems;
const secondarySlots = Array.from({ length: 2 }, (_, index) => secondaryItems[index] ?? null);
if (!primaryItem) return null; const featuredOverviewLink =
categoryLinks.get(categoryNames.tinVcci.toLowerCase()) ?? FALLBACK_CATEGORY_LINK;
return ( return (
<section className="py-8 md:py-10"> <section className="py-8 md:py-10">
...@@ -61,7 +28,7 @@ function FeaturedNews() { ...@@ -61,7 +28,7 @@ function FeaturedNews() {
</div> </div>
<Link <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]" 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> <span>Xem tất cả</span>
...@@ -70,70 +37,97 @@ function FeaturedNews() { ...@@ -70,70 +37,97 @@ function FeaturedNews() {
</div> </div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.14fr)_minmax(0,0.96fr)]"> <div className="grid gap-5 xl:grid-cols-[minmax(0,1.14fr)_minmax(0,0.96fr)]">
<Link {primaryItem ? (
href={getNewsLink(primaryItem)} <Link
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]" 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 <div className="relative h-full min-h-[260px] md:min-h-[320px] xl:min-h-[350px]">
src={primaryItem.thumbnail?.url ?? "/thumbnail.png"} <ImageNext
alt={primaryItem.thumbnail?.alt || primaryItem.title} src={primaryItem.thumbnail?.url ?? "/thumbnail.png"}
width={1200} alt={primaryItem.thumbnail?.alt || primaryItem.title}
height={800} width={1200}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]" 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="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]"> <div className="relative flex h-full flex-col justify-end p-4 md:p-5">
{getBadgeLabel(primaryItem)} <span className="mb-2 inline-flex w-fit rounded-[10px] bg-[#ffc400] px-3 py-1 text-sm font-bold text-[#1d3f90]">
</span> {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 className="max-w-3xl text-[20px] font-bold leading-[1.28] text-white md:text-[28px] xl:text-[32px]">
</h3> {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 className="mt-2 text-base font-medium text-white/78 md:text-[17px]">
</p> {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>
</div> </div>
</Link> )}
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{secondaryItems.map((item) => ( {secondarySlots.map((item, index) =>
<Link item ? (
key={item.id} <Link
href={getNewsLink(item)} key={item.id}
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]" 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 <div className="relative h-full min-h-[195px] md:min-h-[205px] xl:min-h-[215px]">
src={item.thumbnail?.url ?? "/thumbnail.png"} <ImageNext
alt={item.thumbnail?.alt || item.title} src={item.thumbnail?.url ?? "/thumbnail.png"}
width={600} alt={item.thumbnail?.alt || item.title}
height={420} width={600}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.05]" 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="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]"> <div className="relative flex h-full flex-col justify-end p-3.5">
{getBadgeLabel(item)} <span className="mb-2 inline-flex w-fit rounded-[10px] bg-[#ffc400] px-3 py-1 text-sm font-bold text-[#1d3f90]">
</span> {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 className="line-clamp-2 text-[16px] font-bold leading-[1.32] text-white md:text-[17px]">
</h4> {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 className="mt-1.5 text-[15px] font-medium text-white/78 md:text-base">
</p> {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>
</div> </div>
</Link> ),
))} )}
</div> </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"> <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'; 'use client';
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import memberImages from "@/constants/memberImages"; import memberImages from "@/constants/memberImages";
import {
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import Link from "next/link"; 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() { 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 ( return (
<section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch"> <section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch">
...@@ -36,7 +23,7 @@ function Members() { ...@@ -36,7 +23,7 @@ function Members() {
</div> </div>
<Link <Link
href="/danh-ba-hoi-vien" href={sectionLink}
className="pt-1 text-sm font-semibold text-[#1e2f5e] transition-colors hover:text-[#20449a]" className="pt-1 text-sm font-semibold text-[#1e2f5e] transition-colors hover:text-[#20449a]"
> >
Xem thêm Xem thêm
...@@ -77,7 +64,7 @@ function Members() { ...@@ -77,7 +64,7 @@ function Members() {
{featuredConnection ? ( {featuredConnection ? (
<Link <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)]" 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]"> <div className="aspect-[1.25/1] overflow-hidden rounded-[20px]">
...@@ -90,7 +77,11 @@ function Members() { ...@@ -90,7 +77,11 @@ function Members() {
/> />
</div> </div>
</Link> </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> </aside>
</section> </section>
); );
......
'use client'; 'use client';
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import stripImagesAndHtml from "@/helpers/stripImageAndHtml"; import stripImagesAndHtml from "@/helpers/stripImageAndHtml";
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
...@@ -13,60 +10,32 @@ import { useMemo, useState } from "react"; ...@@ -13,60 +10,32 @@ import { useMemo, useState } from "react";
const tabs = [ const tabs = [
{ id: "all", label: "Tất cả" }, { id: "all", label: "Tất cả" },
{ id: "tin-vcci", label: "Tin VCCI" }, { id: "tin-vcci", label: "Tin VCCI" },
{ id: "tin-kinh-te", label: "Tin Kinh Tế" }, { id: "tin-kinh-te", label: "Tin Kinh tế" },
{ id: "chuyen-de", label: "Chuyên Đề" }, { 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() { function News() {
const [tab, setTab] = useState("all"); const [tab, setTab] = useState("all");
const { newsTabs, categoryLinks, categoryNames } = useHomePosts();
const filteredItems = useMemo( const filteredItems = useMemo(() => {
() => allNewsItems.filter((item) => matchesTab(item, tab)), if (tab === "all") return newsTabs.all;
[tab], 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); const listArticles = filteredItems.slice(1, 5);
const listSlots = Array.from({ length: 4 }, (_, index) => listArticles[index] ?? null);
if (!featuredArticle) return 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 ( return (
<div className="flex-1"> <div className="flex-1">
...@@ -102,59 +71,93 @@ function News() { ...@@ -102,59 +71,93 @@ function News() {
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.02fr)_minmax(320px,0.98fr)]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,1.02fr)_minmax(320px,0.98fr)]">
<div> <div>
<Link {featuredArticle ? (
href="/hoat-dong/tin-tuc" <Link
className="block h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]" 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 <div className="aspect-[1.75/1] overflow-hidden">
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"} <ImageNext
alt={featuredArticle.thumbnail?.alt || featuredArticle.title} src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
width={720} alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
height={580} width={720}
className="h-full w-full object-cover" height={580}
/> className="h-full w-full object-cover"
</div> />
</div>
<div className="space-y-1.5 p-3">
<span className="inline-flex text-[14px] font-bold text-[#e2a500]"> <div className="space-y-1.5 p-3">
{getTabLabel(featuredArticle)} <span className="inline-flex text-[14px] font-bold text-[#e2a500]">
</span> {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 className="line-clamp-2 text-[16px] font-bold leading-[1.28] text-[#20408f] md:text-[17px]">
</h3> {featuredArticle.title}
</h3>
<p className="line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]">
{stripImagesAndHtml(featuredArticle.summary)} <p className="line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]">
</p> {stripImagesAndHtml(featuredArticle.summary)}
</p>
<p className="text-[14px] text-[#8a9bb6]"> <p className="text-[14px] text-[#8a9bb6]">
{dayjs(featuredArticle.published_at || featuredArticle.created_at).format("DD/MM/YYYY")} {dayjs(featuredArticle.publishedAt || featuredArticle.createdAt).format(
</p> "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> </div>
</Link> )}
</div> </div>
<div className="xl:flex xl:h-full xl:flex-col"> <div className="xl:flex xl:h-full xl:flex-col">
<div className="space-y-3 xl:flex xl:flex-1 xl:flex-col"> <div className="space-y-3 xl:flex xl:flex-1 xl:flex-col">
{listArticles.map((news) => ( {listSlots.map((news, index) =>
<Link news ? (
key={news.id} <Link
href="/hoat-dong/tin-tuc" key={news.id}
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" 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 className="line-clamp-2 text-[15px] font-bold leading-[1.28] text-[#21408f]">
</h4> {news.title}
<p className="mt-1 text-[13px] text-[#8a9bb6]"> </h4>
{dayjs(news.published_at || news.created_at).format("DD/MM/YYYY")} <p className="mt-1 text-[13px] text-[#8a9bb6]">
</p> {dayjs(news.publishedAt || news.createdAt).format("DD/MM/YYYY")}
</Link> </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>
</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> </div>
); );
} }
......
'use client'; 'use client';
import { import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import Link from "next/link"; 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() { function PolicyAndLaws() {
const { policyPosts, categoryLinks, categoryNames } = useHomePosts();
const policyItems = policyPosts;
const [featuredItem, ...listItems] = policyItems; const [featuredItem, ...listItems] = policyItems;
const listSlots = [featuredItem, ...listItems.slice(0, 2)];
if (!featuredItem) return null; const sectionLink =
categoryLinks.get(categoryNames.chinhSachPhapLuat.toLowerCase()) ??
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat";
return ( return (
<section className="flex-1"> <section className="flex-1">
...@@ -46,7 +25,7 @@ function PolicyAndLaws() { ...@@ -46,7 +25,7 @@ function PolicyAndLaws() {
</div> </div>
<Link <Link
href="/thong-tin-truyen-thong/phap-luat" href={sectionLink}
className="text-[#24469c] transition-colors hover:text-[#1b55a1]" className="text-[#24469c] transition-colors hover:text-[#1b55a1]"
> >
<ChevronRight className="h-5 w-5" /> <ChevronRight className="h-5 w-5" />
...@@ -54,23 +33,38 @@ function PolicyAndLaws() { ...@@ -54,23 +33,38 @@ function PolicyAndLaws() {
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
{[featuredItem, ...listItems.slice(0, 2)].map((item, index) => ( {listSlots.map((item, index) =>
<Link item ? (
key={item.id} <Link
href="/thong-tin-truyen-thong/phap-luat" key={item.id}
className={`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${ href={item.externalLink}
index === 0 ? "pt-0.5" : "" 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"> <span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<h3 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798] md:text-[16px]"> <div className="min-w-0">
{item.title} <h3 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798] md:text-[16px]">
</h3> {item.title}
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">{formatPublishDate(item)}</p> </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> </div>
</Link> ),
))} )}
</div> </div>
</section> </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"; "use client";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { notFound, useParams, useRouter } from "next/navigation"; import { notFound, useParams, useRouter } from "next/navigation";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config"; import { useQuery } from "@tanstack/react-query";
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config"; import { Spinner } from "@/components/ui";
// templates
import InformationPage from "./templates/InformationPage";
import ArticlePage from "./templates/ArticlePage"; import ArticlePage from "./templates/ArticlePage";
import ArticleDetailPage from "./templates/ArticleDetailPage"; import ArticleDetailPage from "./templates/ArticleDetailPage";
import EventPage from "./templates/EventPage"; import InformationPage from "./templates/InformationPage";
import EventDetailPage from "./templates/EventDetailPage"; import {
import { Spinner } from "@/components/ui"; fetchDynamicCategories,
import { GetNewsResponseType } from "@/api/types/news"; fetchDynamicPostByExternalLink,
import { useGetNews } from "@/api/endpoints/news"; fetchDynamicSinglePagePost,
findDynamicCategoryByPath,
findFirstChildCategory,
findMenuCategoryForPost,
} from "./templates/data";
export default function DynamicPage() { export default function DynamicPage() {
const params = useParams(); const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug]; const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/"); const path = slug.join("/");
const routePath = `/${path}`;
const router = useRouter(); const router = useRouter();
// query const categoryQuery = useQuery({
const { data: news } = useGetNews<GetNewsResponseType>( queryKey: ["dynamic-categories"],
{ filters: `external_link==/${path}` } 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(() => { useEffect(() => {
if (!category) return; if (!matchedCategory || matchedCategory.type !== "category") return;
if (slug.length === 1 && children.length > 0) {
const firstChild = children[0]; const firstChild = findFirstChildCategory(matchedCategory, categoryQuery.data ?? []);
if (firstChild?.static_link) { if (slug.length === 1 && firstChild?.url) {
router.push(firstChild.static_link); router.replace(firstChild.url);
}
} }
}, [slug, category, children, router]); }, [matchedCategory, categoryQuery.data, router, slug.length]);
//template const isLoading =
if (slug[0] === "hoat-dong" && slug[1] === "su-kien") { categoryQuery.isLoading ||
if (slug.length === 2) return <EventPage />; detailQuery.isLoading ||
if (slug.length === 3) return <EventDetailPage />; (resolvedCategory?.type === "page" && singlePageQuery.isLoading);
}
if (news?.responseData?.count == 0 && isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center w-full h-64"> <div className="flex min-h-[50vh] items-center justify-center">
<Spinner /> <Spinner />
</div> </div>
); );
} }
if (news && news?.responseData.rows.length !== 0) { if (detailQuery.data) {
return <ArticleDetailPage data={news} />; return (
<ArticleDetailPage
post={detailQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
} }
else if (category?.responseData.is_article == true) { if (resolvedCategory?.type === "page") {
return <ArticlePage />; if (!singlePageQuery.data) return notFound();
return (
<InformationPage
post={singlePageQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
} }
else if (category?.responseData.is_article == false) { if (resolvedCategory?.type === "news") {
return <InformationPage />; return (
<ArticlePage
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
} }
else if (isError) { if (resolvedCategory?.type === "category") {
return notFound(); return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
} }
}
\ No newline at end of file return notFound();
}
'use client'; '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 dayjs from "dayjs";
import parse from "html-react-parser"; 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 }) { type ArticleDetailPageProps = {
const params = useParams(); post: DynamicPostItem;
const slug = Array.isArray(params.slug) ? params.slug : [params.slug]; category: DynamicCategoryRouteItem | null;
allCategories: DynamicCategoryRouteItem[];
};
//query export default function ArticleDetailPage({
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({ post,
code: slug[0], category,
}); allCategories,
}: ArticleDetailPageProps) {
const categoryMenu = category
? buildDynamicCategoryMenu(category, allCategories)
: [];
const children = category?.responseData?.children ?? [];
// template
return ( return (
<div className='container w-full flex justify-center items-center pb-10'> <div className="container w-full flex justify-center items-center pb-10">
<div className='flex flex-col gap-5 w-full'> <div className="flex flex-col gap-5 w-full">
{children.length !== 0 ? ( {categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<ListCategory categories={children} />
) : (
<br />
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <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"> <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'> <div className="pb-5 text-primary text-2xl leading-normal font-medium">
{data?.responseData?.rows[0]?.title} {post.title}
</div> </div>
<div className='flex items-center gap-2 text-sm mb-4'> <div className="flex items-center gap-2 text-sm mb-4">
<span className='text-base text-blue-700'> <span className="text-base text-blue-700">
{dayjs(data?.responseData?.rows[0]?.created_at).format('DD/MM/YYYY')} {dayjs(post.release_at ?? post.published_at ?? post.created_at).format("DD/MM/YYYY")}
</span> </span>
</div> </div>
<hr className="my-5" /> <hr className="my-5" />
<div className='flex-1 text-app-grey text-base overflow-hidden'> <div className="flex-1 text-app-grey text-base overflow-hidden">
<div className="prose tiptap overflow-hidden"> <div className="prose tiptap max-w-none overflow-hidden">
{parse(data?.responseData?.rows[0]?.description ?? '')} {parse(getDynamicPostBodyHtml(post))}
</div> </div>
</div> </div>
</main> </main>
...@@ -52,4 +53,4 @@ export default function ArticleDetailPage({ data }: { data: GetNewsResponseType ...@@ -52,4 +53,4 @@ export default function ArticleDetailPage({ data }: { data: GetNewsResponseType
</div> </div>
</div> </div>
); );
} }
\ No newline at end of file
'use client'; 'use client';
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config"; import { useEffect, useMemo, useState } from "react";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import ListCategory from "@/components/base/list-category"; import { useQuery } from "@tanstack/react-query";
import { useParams, useSearchParams, useRouter, usePathname } from "next/navigation"; import { Spinner } from "@/components/ui";
import { useGetNews } from "@/api/endpoints/news";
import { GetNewsResponseType } from "@/api/types/news";
import CardNews from "@/components/base/card-news";
import { Pagination } from "@/components/base/pagination"; import { Pagination } from "@/components/base/pagination";
import ListFilter from "@/components/base/list-filter"; import ListFilter from "@/components/base/list-filter";
import EventCalendar from "@/components/base/event-calendar"; import EventCalendar from "@/components/base/event-calendar";
import { useState, useEffect } from "react"; import ListCategory from "@/components/base/list-category";
import { Spinner } from "@/components/ui"; import {
buildDynamicCategoryMenu,
buildPostFilters,
fetchDynamicPostList,
stripHtml,
} from "./data";
import type { DynamicCategoryRouteItem } from "./types";
import CardNews from "@/components/base/card-news";
export default function ArticlePage() { type ArticlePageProps = {
// get url category: DynamicCategoryRouteItem;
const params = useParams(); allCategories: DynamicCategoryRouteItem[];
const slug = Array.isArray(params.slug) ? params.slug : [params.slug]; };
const path = slug.join("/");
export default function ArticlePage({ category, allCategories }: ArticlePageProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParamsString = searchParams.toString();
// states
const initialPage = Number(searchParams.get("page") ?? "1"); const initialPage = Number(searchParams.get("page") ?? "1");
const [submitSearch, setSubmitSearch] = useState(""); const [submitSearch, setSubmitSearch] = useState("");
const [page, setPage] = useState(initialPage); const [page, setPage] = useState(initialPage);
const pageSize = 5; const pageSize = 6;
const keyword = submitSearch.trim();
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParamsString);
if (page > 1) { if (page > 1) {
params.set("page", String(page)); params.set("page", String(page));
} else { } else {
params.delete("page"); params.delete("page");
} }
const qs = params.toString(); const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); const nextUrl = qs ? `${pathname}?${qs}` : pathname;
}, [page]); const currentUrl = searchParamsString ? `${pathname}?${searchParamsString}` : pathname;
// query if (nextUrl !== currentUrl) {
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({ router.replace(nextUrl, { scroll: false });
code: slug[0], }
}); }, [page, pathname, router, searchParamsString]);
const { data: articles, isLoading: articlesLoading } = useGetNews<GetNewsResponseType>({ useEffect(() => {
filters: `page_config.static_link==/${path}` + (submitSearch ? `,title@=${submitSearch}` : ""), setPage(1);
pageSize: String(pageSize), }, [submitSearch, category.id]);
currentPage: String(page),
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 ?? []; const categoryMenu = useMemo(
//template () => buildDynamicCategoryMenu(category, allCategories),
[category, allCategories],
);
const totalPages = postsQuery.data?.totalPages ?? 1;
const currentPage = Math.min(page, totalPages);
const paginatedPosts = postsQuery.data?.rows ?? [];
return ( return (
<div className="min-h-screen container mx-auto"> <div className="min-h-screen container mx-auto">
{articlesLoading ? ( {postsQuery.isLoading ? (
<div className="flex justify-center items-center w-full h-64"> <div className="flex justify-center items-center w-full h-64">
<Spinner /> <Spinner />
</div> </div>
) : ( ) : (
<div className="w-full flex flex-col gap-5"> <div className="w-full flex flex-col gap-5">
{children.length !== 0 ? ( {categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<ListCategory categories={children} />
) : (
<br />
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<main className="lg:col-span-2 bg-background"> <main className="lg:col-span-2 bg-background">
<div className="pb-5 overflow-hidden"> <div className="pb-5 overflow-hidden">
{articles?.responseData?.rows.map((item) => ( {paginatedPosts.length ? (
<CardNews paginatedPosts.map((item) => {
key={item.id} const fallbackDescription = item.content_structure?.post_content
news={item} ?.map((section) => section.content)
link={`${item.external_link}`} .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"> <div className="w-full flex justify-center mt-4">
<Pagination <Pagination
pageCount={Number(articles?.responseData?.totalPages ?? 1)} pageCount={totalPages}
page={Number(articles?.responseData?.currentPage ?? page)} page={currentPage}
onChangePage={setPage} onChangePage={setPage}
onGoToPreviousPage={() => setPage(Math.max(1, page - 1))} onGoToPreviousPage={() => setPage(Math.max(1, currentPage - 1))}
onGoToNextPage={() => onGoToNextPage={() => setPage(Math.min(totalPages, currentPage + 1))}
setPage(Math.min(Number(articles?.responseData?.totalPages ?? 1), page + 1))
}
/> />
</div> </div>
</div> </div>
</main> </main>
<aside className="space-y-6"> <aside className="space-y-6">
<ListFilter onSearch={setSubmitSearch} /> <ListFilter onSearch={setSubmitSearch} onReset={() => setSubmitSearch("")} />
<EventCalendar /> <EventCalendar />
<div className="bg-white border rounded-md overflow-hidden"> <div className="bg-white border rounded-md overflow-hidden">
<div className="w-full relative bg-gray-100"> <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>
</div> </div>
</aside> </aside>
...@@ -103,4 +170,4 @@ export default function ArticlePage() { ...@@ -103,4 +170,4 @@ export default function ArticlePage() {
)} )}
</div> </div>
); );
} }
\ No newline at end of file
...@@ -23,6 +23,7 @@ export default function EventPage() { ...@@ -23,6 +23,7 @@ export default function EventPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParamsString = searchParams.toString();
// states // states
const initialPage = Number(searchParams.get("page") ?? "1"); const initialPage = Number(searchParams.get("page") ?? "1");
...@@ -31,15 +32,20 @@ export default function EventPage() { ...@@ -31,15 +32,20 @@ export default function EventPage() {
const pageSize = 5; const pageSize = 5;
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParamsString);
if (page > 1) { if (page > 1) {
params.set("page", String(page)); params.set("page", String(page));
} else { } else {
params.delete("page"); params.delete("page");
} }
const qs = params.toString(); const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); const nextUrl = qs ? `${pathname}?${qs}` : pathname;
}, [page]); const currentUrl = searchParamsString ? `${pathname}?${searchParamsString}` : pathname;
if (nextUrl !== currentUrl) {
router.replace(nextUrl, { scroll: false });
}
}, [page, pathname, router, searchParamsString]);
// query // query
const { data: categoriesPage } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({ const { data: categoriesPage } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
...@@ -96,4 +102,4 @@ export default function EventPage() { ...@@ -96,4 +102,4 @@ export default function EventPage() {
</div> </div>
</> </>
); );
} }
\ No newline at end of file
'use client'; '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 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() { type InformationPageProps = {
// get url post: DynamicPostItem;
const params = useParams(); category: DynamicCategoryRouteItem;
const slug = Array.isArray(params.slug) ? params.slug : [params.slug]; allCategories: DynamicCategoryRouteItem[];
const path = slug.join("/"); };
// query
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
static_link: `/${slug[0]}`,
});
const { data: information, isLoading: informationLoading } = useGetNews<GetNewsResponseType>({ export default function InformationPage({
filters: `page_config.static_link==/${path}`, post,
}); category,
allCategories,
}: InformationPageProps) {
const categoryMenu = buildDynamicCategoryMenu(category, allCategories);
const children = category?.responseData?.children ?? [];
//template
return ( return (
<div className='container w-full flex justify-center items-center pb-10'> <div className="container w-full flex justify-center items-center pb-10">
{informationLoading ? ( <div className="flex flex-col gap-5 w-full">
<div className="flex justify-center items-center w-full h-64"> {categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<Spinner /> <main className="bg-white border rounded-md py-10 px-5 md:px-20 lg:px-20">
</div> <div className="text-primary text-2xl leading-normal font-bold">
) : ( {post.title}
<div className='flex flex-col gap-5 w-full'> </div>
{children.length !== 0 ? ( <hr className="my-5" />
<ListCategory categories={children} /> <div className="flex-1 text-app-grey text-base overflow-hidden">
) : ( <div className="prose tiptap max-w-none overflow-hidden">
<br /> {parse(getDynamicPostBodyHtml(post))}
)}
<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> </div>
</main> </div>
</div> </main>
)} </div>
</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() { ...@@ -432,7 +432,11 @@ export default function AdminBaseConfigPage() {
saveConfig(nextConfig); saveConfig(nextConfig);
setSavingItem(false); setSavingItem(false);
setItemDialogOpen(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 = () => { const handleDeleteItem = () => {
...@@ -575,7 +579,7 @@ export default function AdminBaseConfigPage() { ...@@ -575,7 +579,7 @@ export default function AdminBaseConfigPage() {
value="social" 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]" 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> </TabsTrigger>
</TabsList> </TabsList>
...@@ -700,7 +704,7 @@ export default function AdminBaseConfigPage() { ...@@ -700,7 +704,7 @@ export default function AdminBaseConfigPage() {
</div> </div>
) : ( ) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-8 text-center text-sm text-gray-500"> <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>
)} )}
</div> </div>
...@@ -974,7 +978,7 @@ export default function AdminBaseConfigPage() { ...@@ -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"> <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>
)} )}
</div> </div>
...@@ -1018,9 +1022,9 @@ export default function AdminBaseConfigPage() { ...@@ -1018,9 +1022,9 @@ export default function AdminBaseConfigPage() {
<CardHeader> <CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div> <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"> <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> </CardDescription>
</div> </div>
...@@ -1030,7 +1034,7 @@ export default function AdminBaseConfigPage() { ...@@ -1030,7 +1034,7 @@ export default function AdminBaseConfigPage() {
className="rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90" className="rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
> >
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Lưu mạng xã hội Luu m?ng x? h?i
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
......
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Eye, Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog"; import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Select, Select,
SelectContent, SelectContent,
...@@ -177,26 +177,12 @@ export default function AdminContactRequestsPage() { ...@@ -177,26 +177,12 @@ export default function AdminContactRequestsPage() {
{formatDateTime(item.submittedAt)} {formatDateTime(item.submittedAt)}
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1"> <AdminRowActions
<Button actions={[
type="button" { kind: "view", label: "Xem chi tiết đơn", onClick: () => setDetailTarget(item) },
variant="ghost" { kind: "delete", label: "Xóa đơn liên hệ", onClick: () => setDeleteTarget(item) },
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>
</TableRow> </TableRow>
)) ))
......
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Eye, Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog"; import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Table, Table,
TableBody, TableBody,
...@@ -136,26 +136,16 @@ export default function AdminMembershipApplicationsPage() { ...@@ -136,26 +136,16 @@ export default function AdminMembershipApplicationsPage() {
{formatDateTime(item.submittedAt)} {formatDateTime(item.submittedAt)}
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1"> <AdminRowActions
<Button actions={[
type="button" { kind: "view", label: "Xem chi tiết đơn", onClick: () => setDetailTarget(item) },
variant="ghost" {
size="icon" kind: "delete",
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]" label: "Xóa đơn đăng ký hội viên",
onClick={() => setDetailTarget(item)} onClick: () => setDeleteTarget(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>
</TableRow> </TableRow>
)) ))
......
...@@ -2,12 +2,12 @@ ...@@ -2,12 +2,12 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Eye, Mail, Trash2 } from "lucide-react"; import { Mail } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog"; import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Button } from "@/components/ui/button";
import { import {
Table, Table,
TableBody, TableBody,
...@@ -116,28 +116,14 @@ export default function AdminNewsletterEmailsPage() { ...@@ -116,28 +116,14 @@ export default function AdminNewsletterEmailsPage() {
<TableCell className="py-3 text-center text-sm text-gray-700"> <TableCell className="py-3 text-center text-sm text-gray-700">
{formatDateTime(item.submittedAt)} {formatDateTime(item.submittedAt)}
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1"> <AdminRowActions
<Button actions={[
type="button" { kind: "view", label: "Xem chi tiết email", onClick: () => setDetailTarget(item) },
variant="ghost" { kind: "delete", label: "Xóa email đăng ký", onClick: () => setDeleteTarget(item) },
size="icon" ]}
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]" />
onClick={() => setDetailTarget(item)} </TableCell>
>
<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>
</TableRow> </TableRow>
)) ))
)} )}
......
...@@ -159,7 +159,7 @@ export default function AdminDashboardPage() { ...@@ -159,7 +159,7 @@ export default function AdminDashboardPage() {
() => [ () => [
{ {
title: "Cấu hình chung", 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", href: "/admin/base-config",
icon: Globe, icon: Globe,
}, },
...@@ -313,7 +313,7 @@ export default function AdminDashboardPage() { ...@@ -313,7 +313,7 @@ export default function AdminDashboardPage() {
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{activeBanners.length}</div> <div className="mt-2 text-2xl font-semibold text-[#163b73]">{activeBanners.length}</div>
</div> </div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4"> <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 className="mt-2 text-2xl font-semibold text-[#163b73]">{visibleSocials.length}</div>
</div> </div>
</div> </div>
......
"use client"; "use client";
import React from "react"; import React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
...@@ -7,27 +7,18 @@ import { useParams, useRouter } from "next/navigation"; ...@@ -7,27 +7,18 @@ import { useParams, useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
ArrowLeft, ArrowLeft,
Edit,
EyeOff, EyeOff,
FileText, FileText,
MoreHorizontal,
Plus, Plus,
Star, Star,
Trash2,
} from "lucide-react"; } from "lucide-react";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image"; import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Table, Table,
TableBody, TableBody,
...@@ -358,37 +349,22 @@ export default function HeaderCategoryPostsPage() { ...@@ -358,37 +349,22 @@ export default function HeaderCategoryPostsPage() {
)} )}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<DropdownMenu> <AdminRowActions
<DropdownMenuTrigger asChild> actions={[
<Button {
variant="ghost" kind: "edit",
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]" label: "Chỉnh sửa bài viết",
> onClick: () => router.push(`/admin/header-config/${categoryId}/posts/${item.id}`),
<MoreHorizontal className="h-4 w-4" /> },
</Button> {
</DropdownMenuTrigger> kind: "delete",
<DropdownMenuContent align="end"> label: "Xóa bài viết",
<DropdownMenuItem onClick: () => setDeleteTarget(item),
asChild },
className="text-gray-700 focus:text-[#063e8e]" ]}
> />
<Link href={`/admin/header-config/${categoryId}/posts/${item.id}`}> </TableCell>
<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>
</TableRow> </TableRow>
)) ))
)} )}
......
...@@ -5,24 +5,14 @@ import Link from "next/link"; ...@@ -5,24 +5,14 @@ import Link from "next/link";
import { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Edit,
ExternalLink, ExternalLink,
FileText, FileText,
FolderTree, FolderTree,
MoreHorizontal,
Plus, Plus,
Trash,
} from "lucide-react"; } from "lucide-react";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Badge } from "@/components/ui/badge"; 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 { Skeleton } from "@/components/ui/skeleton";
import { import {
Table, Table,
...@@ -42,6 +32,8 @@ export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & { ...@@ -42,6 +32,8 @@ export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
parentId: string | null; parentId: string | null;
}; };
const PROTECTED_HOME_CATEGORY_ID = "root-home";
interface HeaderCategoryTableProps { interface HeaderCategoryTableProps {
rows: HeaderCategoryFlatRow[]; rows: HeaderCategoryFlatRow[];
expanded: Record<string, boolean>; expanded: Record<string, boolean>;
...@@ -160,8 +152,11 @@ export function HeaderCategoryTable({ ...@@ -160,8 +152,11 @@ export function HeaderCategoryTable({
rows.map((item, index) => { rows.map((item, index) => {
const hasChildren = item.children.length > 0; const hasChildren = item.children.length > 0;
const isExpanded = expanded[item.id] ?? true; const isExpanded = expanded[item.id] ?? true;
const canCreateChild = !item.parent_id && item.type === "category"; const isProtectedHomeCategory = item.id === PROTECTED_HOME_CATEGORY_ID;
const canManagePosts = item.type === "page" || item.type === "news"; const canCreateChild =
!isProtectedHomeCategory && !item.parent_id && item.type === "category";
const canManagePosts =
!isProtectedHomeCategory && (item.type === "page" || item.type === "news");
return ( return (
<TableRow <TableRow
...@@ -221,56 +216,46 @@ export function HeaderCategoryTable({ ...@@ -221,56 +216,46 @@ export function HeaderCategoryTable({
</TableCell> </TableCell>
<TableCell className="w-[120px] text-center"> <TableCell className="w-[120px] text-center">
<DropdownMenu> <AdminRowActions
<DropdownMenuTrigger asChild> actions={[
<Button ...(!isProtectedHomeCategory
variant="ghost" ? [
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]" {
> kind: "edit" as const,
<MoreHorizontal className="h-4 w-4" /> label: "Chỉnh sửa danh mục",
</Button> onClick: () => onEdit(item),
</DropdownMenuTrigger> },
<DropdownMenuContent align="end"> ]
<DropdownMenuItem : []),
className="text-gray-700 focus:text-[#063e8e]" ...(canManagePosts
onClick={() => onEdit(item)} ? [
> {
<Edit className="mr-2 h-4 w-4" /> kind: "manage" as const,
Chỉnh sửa label: "Quản lý bài viết",
</DropdownMenuItem> href: `/admin/header-config/${item.id}/posts`,
},
{canManagePosts ? ( ]
<DropdownMenuItem : []),
asChild ...(canCreateChild
className="text-gray-700 focus:text-[#063e8e]" ? [
> {
<Link href={`/admin/header-config/${item.id}/posts`}> kind: "create-child" as const,
<FileText className="mr-2 h-4 w-4" /> label: "Thêm danh mục con",
Quản lý bài viết onClick: () => onCreateChild(item),
</Link> },
</DropdownMenuItem> ]
) : null} : []),
...(!isProtectedHomeCategory
{canCreateChild ? ( ? [
<DropdownMenuItem {
className="text-gray-700 focus:text-[#063e8e]" kind: "delete" as const,
onClick={() => onCreateChild(item)} label: "Xóa danh mục",
> onClick: () => onDelete(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>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
......
...@@ -34,6 +34,12 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = { ...@@ -34,6 +34,12 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
description: "", 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 { function toFormValues(item?: CmsHeaderCategoryItem | null): HeaderCategoryFormValues {
if (!item) return EMPTY_HEADER_CATEGORY_FORM; if (!item) return EMPTY_HEADER_CATEGORY_FORM;
...@@ -199,6 +205,8 @@ export default function HeaderConfigPage() { ...@@ -199,6 +205,8 @@ export default function HeaderConfigPage() {
}; };
const openEdit = (item: HeaderCategoryTreeItem) => { const openEdit = (item: HeaderCategoryTreeItem) => {
if (isProtectedHomeCategory(item.id)) return;
const fullItem = itemMap.get(item.id) ?? null; const fullItem = itemMap.get(item.id) ?? null;
setFormMode("edit"); setFormMode("edit");
setFormValues(toFormValues(fullItem)); setFormValues(toFormValues(fullItem));
...@@ -223,6 +231,10 @@ export default function HeaderConfigPage() { ...@@ -223,6 +231,10 @@ export default function HeaderConfigPage() {
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting) return; if (isSubmitting) return;
if (isProtectedHomeCategory(formValues.id)) {
return;
}
if (!formValues.name.trim()) { if (!formValues.name.trim()) {
toast.error("Tên danh mục là bắt buộc"); toast.error("Tên danh mục là bắt buộc");
return; return;
...@@ -293,6 +305,11 @@ export default function HeaderConfigPage() { ...@@ -293,6 +305,11 @@ export default function HeaderConfigPage() {
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget || isSubmitting) return; if (!deleteTarget || isSubmitting) return;
if (isProtectedHomeCategory(deleteTarget.id)) {
setDeleteTarget(null);
return;
}
setIsSubmitting(true); setIsSubmitting(true);
try { try {
......
...@@ -14,33 +14,17 @@ import { ...@@ -14,33 +14,17 @@ import {
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { postAuthLogin } from "@/api/endpoints/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import logo from "@/assets/VCCI-HCM-logo-VN-2025.png"; import logo from "@/assets/VCCI-HCM-logo-VN-2025.png";
import { loginAdmin } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore"; import useAuthStore from "@/store/useAuthStore";
type AuthMode = "login" | "forgot" | "reset"; type AuthMode = "login" | "forgot" | "reset";
type ResetStep = "request" | "verify" | "password" | "done"; 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> = { type ApiEnvelope<T = unknown> = {
responseData?: T; responseData?: T;
data?: { data?: {
...@@ -141,8 +125,8 @@ function AuthShell({ ...@@ -141,8 +125,8 @@ function AuthShell({
mode === "login" mode === "login"
? "Truy cập khu vực quản trị nội dung VCCI News." ? "Truy cập khu vực quản trị nội dung VCCI News."
: mode === "forgot" : mode === "forgot"
? "Xác thực email quản trị để nhận mã OTP." ? "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."; : "Nh?p m? OTP v? t?o m?t kh?u m?i cho t?i kho?n.";
return ( return (
<div className="min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700"> <div className="min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700">
...@@ -290,7 +274,6 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) { ...@@ -290,7 +274,6 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const hasHydrated = useAuthStore((state) => state._hasHydrated); const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn); const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const rememberState = useAuthStore((state) => state.appUserRemember); const rememberState = useAuthStore((state) => state.appUserRemember);
const setAppToken = useAuthStore((state) => state.setAppToken);
const setAppUserRemember = useAuthStore((state) => state.setAppUserRemember); const setAppUserRemember = useAuthStore((state) => state.setAppUserRemember);
const [mode, setMode] = useState<AuthMode>("login"); const [mode, setMode] = useState<AuthMode>("login");
...@@ -329,18 +312,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) { ...@@ -329,18 +312,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setLoginLoading(true); setLoginLoading(true);
try { try {
const response = await postAuthLogin({ await loginAdmin(email.trim(), password);
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);
setAppUserRemember(remember ? email.trim() : "", remember ? password : "", remember); setAppUserRemember(remember ? email.trim() : "", remember ? password : "", remember);
toast.success("Đăng nhập quản trị thành công"); toast.success("Đăng nhập quản trị thành công");
...@@ -365,9 +337,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) { ...@@ -365,9 +337,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetStep("verify"); setResetStep("verify");
setMode("reset"); 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) { } 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 { } finally {
setResetLoading(false); setResetLoading(false);
} }
...@@ -390,14 +362,14 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) { ...@@ -390,14 +362,14 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const payload = getResponseData<VerifyOtpPayload>(response); const payload = getResponseData<VerifyOtpPayload>(response);
if (!payload?.reset_token) { 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); setResetToken(payload.reset_token);
setResetStep("password"); setResetStep("password");
setResetMessage("OTP hợp lệ. Bạn có thể tạo mật khẩu mới."); setResetMessage("OTP hợp lệ. Bạn có thể tạo mật khẩu mới.");
} catch (error) { } 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 { } finally {
setResetLoading(false); setResetLoading(false);
} }
...@@ -571,7 +543,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) { ...@@ -571,7 +543,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
Đang gửi OTP... Đang gửi OTP...
</> </>
) : ( ) : (
"Gửi mã OTP" "G?i m? OTP"
)} )}
</Button> </Button>
...@@ -621,7 +593,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) { ...@@ -621,7 +593,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<form className="space-y-5" onSubmit={handleVerifyOtp}> <form className="space-y-5" onSubmit={handleVerifyOtp}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="otp" className="text-gray-700"> <Label htmlFor="otp" className="text-gray-700">
Mã OTP M? OTP
</Label> </Label>
<Input <Input
id="otp" id="otp"
...@@ -650,7 +622,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) { ...@@ -650,7 +622,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
onClick={switchToForgot} onClick={switchToForgot}
className="h-10 w-full rounded-xl text-gray-700 hover:bg-[#edf4ff] hover:text-[#063e8e]" 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> </Button>
</form> </form>
) : null} ) : null}
...@@ -700,7 +672,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) { ...@@ -700,7 +672,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<div className="space-y-5"> <div className="space-y-5">
<div className="rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center"> <div className="rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-[#063e8e]" /> <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"> <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. 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> </p>
......
...@@ -429,7 +429,7 @@ export default function AdminMediaPage() { ...@@ -429,7 +429,7 @@ export default function AdminMediaPage() {
</div> </div>
<h2 className="mt-5 text-lg font-semibold text-slate-800">Chưa có ảnh phù hợp</h2> <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"> <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> </p>
</div> </div>
) : ( ) : (
......
"use client"; "use client";
import * as React from "react"; 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 { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -206,26 +207,12 @@ export default function AdminMemberFieldsPage() { ...@@ -206,26 +207,12 @@ export default function AdminMemberFieldsPage() {
{item.name} {item.name}
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1"> <AdminRowActions
<Button actions={[
type="button" { kind: "edit", label: "Chỉnh sửa lĩnh vực", onClick: () => openEdit(item) },
variant="ghost" { kind: "delete", label: "Xóa lĩnh vực", onClick: () => setDeleteTarget(item) },
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>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
......
"use client"; "use client";
import * as React from "react"; 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 { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image"; import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Select, Select,
SelectContent, SelectContent,
...@@ -258,34 +252,20 @@ export default function AdminMembersPage() { ...@@ -258,34 +252,20 @@ export default function AdminMembersPage() {
<span className="line-clamp-2">{item.address || "—"}</span> <span className="line-clamp-2">{item.address || "—"}</span>
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-center"> <TableCell className="px-4 py-3 text-center">
<DropdownMenu> <AdminRowActions
<DropdownMenuTrigger asChild> actions={[
<Button {
variant="ghost" kind: "edit",
size="icon" label: "Chỉnh sửa hội viên",
className="h-8 w-8 hover:bg-[#063e8e]/10" onClick: () => router.push(`/admin/members/${item.id}`),
> },
<MoreHorizontal className="h-4 w-4" /> {
</Button> kind: "delete",
</DropdownMenuTrigger> label: "Xóa hội viên",
<DropdownMenuContent align="end" className="border-[#063e8e]/15"> onClick: () => setDeleteTarget(item),
<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>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
......
"use client"; "use client";
import * as React from "react"; 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 { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -206,26 +207,12 @@ export default function AdminMemberRegionsPage() { ...@@ -206,26 +207,12 @@ export default function AdminMemberRegionsPage() {
{item.name} {item.name}
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1"> <AdminRowActions
<Button actions={[
type="button" { kind: "edit", label: "Chỉnh sửa khu vực", onClick: () => openEdit(item) },
variant="ghost" { kind: "delete", label: "Xóa khu vực", onClick: () => setDeleteTarget(item) },
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>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
......
...@@ -3,30 +3,31 @@ ...@@ -3,30 +3,31 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
Edit, Check,
ChevronsUpDown,
EyeOff, EyeOff,
MoreHorizontal,
Plus, Plus,
Star, Star,
Tag, Tag,
Trash2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image"; import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, Command,
DropdownMenuContent, CommandEmpty,
DropdownMenuItem, CommandInput,
DropdownMenuSeparator, CommandItem,
DropdownMenuTrigger, CommandList,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
...@@ -53,7 +54,12 @@ import { ...@@ -53,7 +54,12 @@ import {
ADMIN_NEWS_TYPE_OPTIONS, ADMIN_NEWS_TYPE_OPTIONS,
type AdminNewsItem, type AdminNewsItem,
} from "@/mockdata/admin-news"; } 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 = 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]"; "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"; ...@@ -63,12 +69,126 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName = const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"; "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) { function formatDateTime(value: string) {
return value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "—"; return value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "—";
} }
function stripHtml(html: string) { function useDebouncedValue<T>(value: T, delay = 350) {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); 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() { function AdminNewsTableLoading() {
...@@ -98,10 +218,40 @@ export default function AdminNewsPage() { ...@@ -98,10 +218,40 @@ export default function AdminNewsPage() {
const [page, setPage] = React.useState(1); const [page, setPage] = React.useState(1);
const [pageSize] = React.useState(20); const [pageSize] = React.useState(20);
const [total, setTotal] = React.useState(0); 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 () => { const load = React.useCallback(async () => {
setReady(false);
const [newsData, headerConfig] = await Promise.all([ 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(), fetchHeaderConfigItems(),
]); ]);
...@@ -109,58 +259,36 @@ export default function AdminNewsPage() { ...@@ -109,58 +259,36 @@ export default function AdminNewsPage() {
setTotal(newsData.total); setTotal(newsData.total);
setHeaderItems(headerConfig.items); setHeaderItems(headerConfig.items);
setReady(true); setReady(true);
}, [page, pageSize]); }, [apiFilters, page, pageSize]);
React.useEffect(() => { React.useEffect(() => {
void load().catch((error) => { 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); setReady(true);
}); });
}, [load]); }, [load]);
React.useEffect(() => {
setPage((currentPage) => (currentPage === 1 ? currentPage : 1));
}, [apiFilters, typeFilter]);
const categoryOptions = React.useMemo(() => { 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]); }, [headerItems]);
const filteredItems = React.useMemo(() => { const filteredItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase(); if (typeFilter === "all") {
return items;
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();
return rightTime - leftTime; return items.filter((item) => item.type === typeFilter);
}); }, [items, typeFilter]);
}, [categoryFilter, headerItems, items, search, statusFilter, typeFilter]);
const stats = React.useMemo(() => { const stats = React.useMemo(() => {
return [ return [
...@@ -171,16 +299,16 @@ export default function AdminNewsPage() { ...@@ -171,16 +299,16 @@ export default function AdminNewsPage() {
}, },
{ {
label: "Đang hiển thị", 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]" />, icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
}, },
{ {
label: "Tin nổi bật", 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]" />, icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
}, },
]; ];
}, [total, items]); }, [filteredItems, total]);
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget || isDeleting) return; if (!deleteTarget || isDeleting) return;
...@@ -193,7 +321,9 @@ export default function AdminNewsPage() { ...@@ -193,7 +321,9 @@ export default function AdminNewsPage() {
setDeleteTarget(null); setDeleteTarget(null);
await load(); await load();
} catch (error) { } 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 { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
...@@ -240,25 +370,11 @@ export default function AdminNewsPage() { ...@@ -240,25 +370,11 @@ export default function AdminNewsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}> <CategoryFilterCombobox
<SelectTrigger className={selectTriggerClassName}> value={categoryFilter}
<SelectValue placeholder="Danh mục hiển thị" /> options={categoryOptions}
</SelectTrigger> onChange={setCategoryFilter}
<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>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className={selectTriggerClassName}> <SelectTrigger className={selectTriggerClassName}>
...@@ -391,35 +507,20 @@ export default function AdminNewsPage() { ...@@ -391,35 +507,20 @@ export default function AdminNewsPage() {
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<DropdownMenu> <AdminRowActions
<DropdownMenuTrigger asChild> actions={[
<Button {
variant="ghost" kind: "edit",
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]" label: "Chỉnh sửa bài viết",
> onClick: () => router.push(`/admin/news/${item.id}`),
<MoreHorizontal className="h-4 w-4" /> },
</Button> {
</DropdownMenuTrigger> kind: "delete",
<DropdownMenuContent align="end"> label: "Xóa bài viết",
<DropdownMenuItem onClick: () => setDeleteTarget(item),
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>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
...@@ -432,7 +533,8 @@ export default function AdminNewsPage() { ...@@ -432,7 +533,8 @@ export default function AdminNewsPage() {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between border-t border-[#063e8e]/10 px-4 py-3"> <div className="flex items-center justify-between border-t border-[#063e8e]/10 px-4 py-3">
<div className="text-sm text-gray-700"> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
......
...@@ -2,9 +2,10 @@ ...@@ -2,9 +2,10 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; 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 { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -30,7 +31,7 @@ import { ...@@ -30,7 +31,7 @@ import {
type CmsTagItem, type CmsTagItem,
createCmsTag, createCmsTag,
deleteCmsTag, deleteCmsTag,
fetchCmsTags, fetchCmsTagsPage,
updateCmsTag, updateCmsTag,
} from "@/lib/api/cms-admin"; } from "@/lib/api/cms-admin";
...@@ -68,12 +69,17 @@ export default function AdminTagsPage() { ...@@ -68,12 +69,17 @@ export default function AdminTagsPage() {
const [formOpen, setFormOpen] = React.useState(false); const [formOpen, setFormOpen] = React.useState(false);
const [formValues, setFormValues] = React.useState<TagFormValues>(EMPTY_FORM); const [formValues, setFormValues] = React.useState<TagFormValues>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = React.useState<CmsTagItem | null>(null); 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 load = React.useCallback(async () => {
const nextItems = await fetchCmsTags(); setIsReady(false);
setItems(nextItems); const result = await fetchCmsTagsPage({ page, pageSize });
setItems(result.items);
setTotal(result.total);
setIsReady(true); setIsReady(true);
}, []); }, [page, pageSize]);
React.useEffect(() => { React.useEffect(() => {
void load().catch((error) => { void load().catch((error) => {
...@@ -93,6 +99,18 @@ export default function AdminTagsPage() { ...@@ -93,6 +99,18 @@ export default function AdminTagsPage() {
); );
}, [items, search]); }, [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 = () => { const openCreate = () => {
setFormValues(EMPTY_FORM); setFormValues(EMPTY_FORM);
setFormOpen(true); setFormOpen(true);
...@@ -176,7 +194,7 @@ export default function AdminTagsPage() { ...@@ -176,7 +194,7 @@ export default function AdminTagsPage() {
actionDisabled={!isReady} actionDisabled={!isReady}
actionMeta={ actionMeta={
<div className="rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]"> <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> </div>
} }
onSearchChange={setSearch} onSearchChange={setSearch}
...@@ -238,32 +256,77 @@ export default function AdminTagsPage() { ...@@ -238,32 +256,77 @@ export default function AdminTagsPage() {
{item.updated_at ? dayjs(item.updated_at).format("DD/MM/YYYY") : "-"} {item.updated_at ? dayjs(item.updated_at).format("DD/MM/YYYY") : "-"}
</TableCell> </TableCell>
<TableCell className="px-4 py-4"> <TableCell className="px-4 py-4">
<div className="flex justify-center gap-2"> <AdminRowActions
<Button actions={[
type="button" { kind: "edit", label: "Chỉnh sửa tag", onClick: () => openEdit(item) },
variant="outline" { kind: "delete", label: "Xóa tag", onClick: () => setDeleteTarget(item) },
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>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </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> </AdminTableLayout>
<Dialog open={formOpen} onOpenChange={setFormOpen}> <Dialog open={formOpen} onOpenChange={setFormOpen}>
......
"use client"; "use client";
import * as React from "react"; 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 Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; 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 { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -273,26 +274,12 @@ export default function AdminVideosPage() { ...@@ -273,26 +274,12 @@ export default function AdminVideosPage() {
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1"> <AdminRowActions
<Button actions={[
type="button" { kind: "edit", label: "Chỉnh sửa video", onClick: () => openEdit(item) },
variant="ghost" { kind: "delete", label: "Xóa video", onClick: () => setDeleteTarget(item) },
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>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
......
...@@ -33,18 +33,24 @@ export function AdminDeleteDialog({ ...@@ -33,18 +33,24 @@ export function AdminDeleteDialog({
}: AdminDeleteDialogProps) { }: AdminDeleteDialogProps) {
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent className="rounded-3xl border border-[#063e8e]/15 bg-white p-6 shadow-xl">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogTitle className="text-xl font-semibold text-gray-900">
<AlertDialogDescription>{description}</AlertDialogDescription> {title}
</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-6 text-gray-700">
{description}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter className="mt-2 gap-2">
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel> <AlertDialogCancel className="mt-0 border-[#063e8e]/15 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900">
<AlertDialogAction className="bg-[#063e8e] hover:bg-[#063e8e]/90" onClick={onConfirm}> {cancelLabel}
</AlertDialogCancel>
<AlertDialogAction className="bg-red-600 text-white hover:bg-red-700" onClick={onConfirm}>
{confirmLabel} {confirmLabel}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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({ ...@@ -66,4 +66,4 @@ export function AdminTableLayout({
</div> </div>
</div> </div>
); );
} }
\ No newline at end of file
...@@ -143,7 +143,7 @@ export function AdminImagePicker({ ...@@ -143,7 +143,7 @@ export function AdminImagePicker({
<ImagePlus className="mb-3 h-10 w-10 text-[#063e8e]" /> <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="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"> <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> </p>
</div> </div>
) : ( ) : (
...@@ -172,7 +172,7 @@ export function AdminImagePicker({ ...@@ -172,7 +172,7 @@ export function AdminImagePicker({
/> />
{item.id === selectedId ? ( {item.id === selectedId ? (
<div className="absolute right-3 top-3 rounded-full bg-[#063e8e] px-2 py-1 text-xs font-medium text-white"> <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> </div>
) : null} ) : null}
</div> </div>
......
...@@ -521,9 +521,14 @@ export function AdminNewsForm({ ...@@ -521,9 +521,14 @@ export function AdminNewsForm({
title: form.title.trim(), title: form.title.trim(),
slug: slugifyAdminNews(form.slug.trim()), slug: slugifyAdminNews(form.slug.trim()),
summary: form.summary, summary: form.summary,
type: form.type,
header_category_id: form.header_category_id, header_category_id: form.header_category_id,
category_ids: 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, tag_ids: form.type === "baiviettrang" ? [] : selectedTagIds,
is_featured: form.type === "tintuc" ? form.is_featured : false, is_featured: form.type === "tintuc" ? form.is_featured : false,
thumbnail_id: form.thumbnail && isUuid(form.thumbnail.id) ? form.thumbnail.id : null, thumbnail_id: form.thumbnail && isUuid(form.thumbnail.id) ? form.thumbnail.id : null,
...@@ -812,30 +817,31 @@ export function AdminNewsForm({ ...@@ -812,30 +817,31 @@ export function AdminNewsForm({
</div> </div>
) : null} ) : 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>
</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> </div>
</FormSection> </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) => { const stripImagesAndHtml = (html?: string) => {
if (!html) return '' if (!html) return "";
// remove img tags first
const withoutImgs = html.replace(/<img[^>]*>/gi, '') const withoutImages = html.replace(/<img[^>]*>/gi, "");
// use DOMParser on client for robust extraction
if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') { if (typeof window !== "undefined" && typeof DOMParser !== "undefined") {
try { try {
const doc = new DOMParser().parseFromString(withoutImgs, 'text/html') const doc = new DOMParser().parseFromString(withoutImages, "text/html");
return doc.body.textContent || '' return doc.body.textContent || "";
} catch { } 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 ( return (
<a <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" 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 <img
src={`${Links.imageEndpoint}${news.thumbnail}`} src={resolveThumbnail(news.thumbnail)}
alt={news.title} alt={news.title}
className="w-full sm:w-56 md:w-64 h-40 md:h-36 object-cover shrink-0" className="w-full sm:w-56 md:w-64 h-40 md:h-36 object-cover shrink-0"
onError={(e) => { 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"> <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} {news.title}
</p> </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 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>
</div> </div>
</a> </a>
) );
} };
export default CardNews; export default CardNews;
\ No newline at end of file
...@@ -7,31 +7,29 @@ import { MenuItem } from "../menu-category"; ...@@ -7,31 +7,29 @@ import { MenuItem } from "../menu-category";
type Category = { type Category = {
id: string; id: string;
name: 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[] }> = ({ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }) => {
categories = [],
}) => {
const pathname = usePathname() || ""; const pathname = usePathname() || "";
const isActive = (href: string) => { const isActive = (href: string) => pathname === href;
return pathname === href;
};
return ( return (
<div className="border-t border-gray-200 bg-white py-2"> <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="w-full px-4 sm:px-6 lg:px-8">
<div className="py-3"> <div className="py-3">
<div className="flex flex-wrap items-center max-w-full overflow-x-auto"> <div className="flex flex-wrap items-center max-w-full overflow-x-auto">
{categories.map((c) => { {categories.map((category) => {
const menu = { id: c.id, name: c.name, link: c.static_link }; const href = resolveHref(category);
const active = isActive(c.static_link); const menu = { id: category.id, name: category.name, link: href };
const active = isActive(href);
return ( return (
<div key={c.id} className="shrink-0"> <div key={category.id} className="shrink-0">
<MenuItem menu={menu} active={active} /> <MenuItem menu={menu} active={active} />
</div> </div>
); );
......
"use client" "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'
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<{ export const ListFilter: React.FC<{
categories?: Category[] categories?: Category[];
onSearch?: (q: string) => void onSearch?: (q: string) => void;
onReset?: () => void onReset?: () => void;
}> = ({ categories, onSearch, onReset }) => { }> = ({ categories, onSearch, onReset }) => {
const [query, setQuery] = useState('') const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(5) const [visibleCount, setVisibleCount] = useState(5);
const [selected, setSelected] = useState<Record<string, boolean>>(() => { const [selected, setSelected] = useState<Record<string, boolean>>(() => {
const map: Record<string, boolean> = {} const map: Record<string, boolean> = {};
if (categories && categories.length) { if (categories?.length) {
categories.forEach((c: Category) => (map[c.id] = false)) 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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setSelected((prev) => { setSelected((prev) => {
const map: Record<string, boolean> = {} const map: Record<string, boolean> = {};
if (categories && categories.length) { if (categories?.length) {
categories.forEach((c: Category) => (map[c.id] = !!prev[c.id])) categories.forEach((category) => {
map[category.id] = Boolean(prev[category.id]);
});
} }
return map return map;
}) });
}, 0) }, 0);
return () => clearTimeout(timer)
}, [categories]) 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 ( return (
<aside className="p-6 bg-white border rounded-md"> <aside className="p-6 bg-white border rounded-md">
...@@ -44,74 +48,82 @@ export const ListFilter: React.FC<{ ...@@ -44,74 +48,82 @@ export const ListFilter: React.FC<{
<div className="mb-4"> <div className="mb-4">
<Input <Input
placeholder="Tên văn bản ..." placeholder="Tên văn bản..."
value={query} 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)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
onSearch?.(query) onSearch?.(query);
} }
}} }}
/> />
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{categories && categories.length > 0 ? ( {categories?.length
categories.slice(0, visibleCount).map((c) => ( ? categories.slice(0, visibleCount).map((category) => (
<label key={c.id} className="flex items-center gap-3"> <label key={category.id} className="flex items-center gap-3">
<Checkbox checked={!!selected[c.id]} onCheckedChange={() => toggle(c.id)} /> <Checkbox
<div className="flex justify-between w-full items-center"> checked={Boolean(selected[category.id])}
<span className="text-sm">{c.title}</span> onCheckedChange={() => toggle(category.id)}
<span className="text-sm text-gray-400">({c.count})</span> />
</div> <div className="flex justify-between w-full items-center">
</label> <span className="text-sm">{category.title}</span>
)) <span className="text-sm text-gray-400">({category.count})</span>
) : null} </div>
</label>
))
: null}
<div className="mt-2 flex items-center gap-3"> <div className="mt-2 flex items-center gap-3">
{(categories?.length ?? 0) > visibleCount && ( {(categories?.length ?? 0) > visibleCount ? (
<button <button
className="text-sm text-primary self-start" className="text-sm text-primary self-start"
onClick={() => setVisibleCount((v) => v + 5)} onClick={() => setVisibleCount((current) => current + 5)}
> >
Xem thêm Xem thêm
</button> </button>
)} ) : null}
{visibleCount > 5 && ( {visibleCount > 5 ? (
<button <button
className="text-sm text-gray-500 self-start" className="text-sm text-gray-500 self-start"
onClick={() => setVisibleCount(5)} onClick={() => setVisibleCount(5)}
> >
Thu gọn Thu gọn
</button> </button>
)} ) : null}
</div> </div>
</div> </div>
<div className="flex gap-3"> <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 Tìm kiếm
</Button> </Button>
<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" className="flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
onClick={() => { onClick={() => {
setQuery('') setQuery("");
// restore initial map const map: Record<string, boolean> = {};
const map: Record<string, boolean> = {} if (categories?.length) {
if (categories && categories.length) { categories.forEach((category) => {
categories.forEach((c) => (map[c.id] = false)) map[category.id] = false;
} });
setSelected(map) }
setVisibleCount(5) setSelected(map);
onReset?.() setVisibleCount(5);
}} onReset?.();
}}
> >
Bỏ tìm Bỏ tìm
</Button> </Button>
</div> </div>
</aside> </aside>
) );
} };
export default ListFilter export default ListFilter;
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { ensureValidAdminAccessToken } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore"; import useAuthStore from "@/store/useAuthStore";
const LOGIN_PATH = "/admin/login"; const LOGIN_PATH = "/admin/login";
...@@ -71,20 +72,62 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) { ...@@ -71,20 +72,62 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const hasHydrated = useAuthStore((state) => state._hasHydrated); const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn); const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const accessToken = useAuthStore((state) => state.appAccessToken); 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(() => { useEffect(() => {
if (!hasHydrated || pathname === LOGIN_PATH) return; if (!hasHydrated || pathname === LOGIN_PATH) return;
if (!isLoggedIn || !accessToken) { let cancelled = false;
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
} const restoreSession = async () => {
}, [accessToken, hasHydrated, isLoggedIn, pathname, router]); 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) { if (pathname === LOGIN_PATH) {
return <>{children}</>; return <>{children}</>;
} }
if (!hasHydrated) { if (!hasHydrated || isRefreshing || isRestoringSession) {
return <AdminAuthLoadingScreen />; return <AdminAuthLoadingScreen />;
} }
......
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { useRouter, usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { LogOut, Menu, ShieldCheck } from 'lucide-react'; import { LogOut, Menu, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; 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 { useSidebarStore } from '@/hooks/use-admin-sidebar';
import useAuthStore from '@/store/useAuthStore'; import useAuthStore from '@/store/useAuthStore';
...@@ -40,23 +39,30 @@ function getTitle(pathname: string): string { ...@@ -40,23 +39,30 @@ function getTitle(pathname: string): string {
return 'Quản trị'; 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() { export function AdminHeader() {
const { toggle } = useSidebarStore(); const { toggle } = useSidebarStore();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const title = getTitle(pathname); const title = getTitle(pathname);
const resetStore = useAuthStore((state) => state.resetStore); const currentUser = useAuthStore((state) => state.appUser);
const handleLogout = async () => { const handleLogout = async () => {
try { await logoutAdmin({ redirectToLogin: true });
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');
}
}; };
return ( return (
...@@ -78,7 +84,7 @@ export function AdminHeader() { ...@@ -78,7 +84,7 @@ export function AdminHeader() {
<div className="flex items-center gap-3"> <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]"> <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]" /> <ShieldCheck className="h-4 w-4 text-[#063e8e]" />
<span>{currentUserRoleLabel}</span> <span>{formatRoles(currentUser?.roles)}</span>
</div> </div>
<Button <Button
variant="outline" variant="outline"
......
...@@ -29,7 +29,7 @@ const AppEditorContent: FC<AppEditorContentProps> = ({ value = '', className = ' ...@@ -29,7 +29,7 @@ const AppEditorContent: FC<AppEditorContentProps> = ({ value = '', className = '
// 3. ✅ Xóa thẻ <a> nhưng giữ lại nội dung // 3. ✅ Xóa thẻ <a> nhưng giữ lại nội dung
if (tagName === 'a') { 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}</>; return <>{children}</>;
} }
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import { useCustomClient } from "@/api/mutator/custom-client"; import { useCustomClient } from "@/api/mutator/custom-client";
import { categoryFallbackRows } from "@/mockdata/categories"; import { categoryFallbackRows } from "@/mockdata/categories";
import useAuthStore from "@/store/useAuthStore";
export type CmsHeaderCategoryType = "category" | "page" | "news"; export type CmsHeaderCategoryType = "category" | "page" | "news";
...@@ -189,16 +188,11 @@ const readMessage = (payload: unknown) => { ...@@ -189,16 +188,11 @@ const readMessage = (payload: unknown) => {
const authHeaders = (withJson = true) => { const authHeaders = (withJson = true) => {
const headers = new Headers(); const headers = new Headers();
const token = useAuthStore.getState().appAccessToken;
if (withJson) { if (withJson) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
} }
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers; return headers;
}; };
...@@ -341,7 +335,9 @@ const transformPost = ( ...@@ -341,7 +335,9 @@ const transformPost = (
slug: post.slug ?? "", slug: post.slug ?? "",
summary: post.summary ?? "", summary: post.summary ?? "",
type: type:
primaryCategoryType === "post" || primaryCategoryType === "page" post.type === "page" ||
primaryCategoryType === "post" ||
primaryCategoryType === "page"
? "baiviettrang" ? "baiviettrang"
: "tintuc", : "tintuc",
header_category_id: primaryCategory?.id ?? "", header_category_id: primaryCategory?.id ?? "",
...@@ -550,6 +546,29 @@ export async function fetchCmsTags() { ...@@ -550,6 +546,29 @@ export async function fetchCmsTags() {
return fetchAllTagsInternal(); 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 }) { export async function createCmsTag(input: { name: string; slug?: string }) {
return cmsRequest<CmsTagItem>("/tag", { return cmsRequest<CmsTagItem>("/tag", {
method: "POST", method: "POST",
...@@ -723,6 +742,7 @@ export async function fetchCmsNewsItems(params?: { ...@@ -723,6 +742,7 @@ export async function fetchCmsNewsItems(params?: {
pageSize?: number; pageSize?: number;
sortField?: string; sortField?: string;
sortOrder?: string; sortOrder?: string;
filters?: string;
}) { }) {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
page: String(params?.page ?? 1), page: String(params?.page ?? 1),
...@@ -731,6 +751,10 @@ export async function fetchCmsNewsItems(params?: { ...@@ -731,6 +751,10 @@ export async function fetchCmsNewsItems(params?: {
sortOrder: params?.sortOrder ?? "desc", sortOrder: params?.sortOrder ?? "desc",
}); });
if (params?.filters?.trim()) {
queryParams.set("filters", params.filters.trim());
}
const result = await cmsRequest<CmsPagedResult<CmsRawPostItem>>( const result = await cmsRequest<CmsPagedResult<CmsRawPostItem>>(
`/post?${queryParams.toString()}`, `/post?${queryParams.toString()}`,
); );
...@@ -757,6 +781,7 @@ export async function createCmsNewsItem(input: { ...@@ -757,6 +781,7 @@ export async function createCmsNewsItem(input: {
title: string; title: string;
slug: string; slug: string;
summary: string; summary: string;
type: "tintuc" | "baiviettrang";
header_category_id: string; header_category_id: string;
category_ids: string[]; category_ids: string[];
tag_ids: string[]; tag_ids: string[];
...@@ -776,6 +801,7 @@ export async function createCmsNewsItem(input: { ...@@ -776,6 +801,7 @@ export async function createCmsNewsItem(input: {
title: input.title, title: input.title,
slug: input.slug, slug: input.slug,
summary: input.summary, summary: input.summary,
type: input.type === "baiviettrang" ? "page" : "news",
external_link: input.slug ? `/${input.slug}` : "/", external_link: input.slug ? `/${input.slug}` : "/",
content: input.summary || "", content: input.summary || "",
category_ids: input.category_ids, category_ids: input.category_ids,
...@@ -816,6 +842,7 @@ export async function updateCmsNewsItem( ...@@ -816,6 +842,7 @@ export async function updateCmsNewsItem(
title: string; title: string;
slug: string; slug: string;
summary: string; summary: string;
type: "tintuc" | "baiviettrang";
header_category_id: string; header_category_id: string;
category_ids: string[]; category_ids: string[];
tag_ids: string[]; tag_ids: string[];
...@@ -836,6 +863,7 @@ export async function updateCmsNewsItem( ...@@ -836,6 +863,7 @@ export async function updateCmsNewsItem(
title: input.title, title: input.title,
slug: input.slug, slug: input.slug,
summary: input.summary, summary: input.summary,
type: input.type === "baiviettrang" ? "page" : "news",
external_link: input.slug ? `/${input.slug}` : "/", external_link: input.slug ? `/${input.slug}` : "/",
content: input.summary || "", content: input.summary || "",
category_ids: input.category_ids, 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 = { ...@@ -123,6 +123,25 @@ export const EMPTY_ADMIN_NEWS_FORM: AdminNewsFormValues = {
post_content: [], 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[] = [ const mediaSeed: AdminMediaItem[] = [
{ {
id: "media-banner", id: "media-banner",
...@@ -179,17 +198,31 @@ function toImageRef(item: AdminMediaItem): AdminNewsImageRef { ...@@ -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[] = [ const newsSeed: AdminNewsItem[] = [
{ {
id: "admin-news-01", 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", slug: "vcci-thuc-day-ket-noi-doanh-nghiep-hoi-vien-khu-vuc-phia-nam",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-news", "cat-activity"], 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, is_featured: true,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -200,15 +233,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -200,15 +233,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-01-a", id: "section-admin-news-01-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -229,14 +262,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -229,14 +262,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-02", 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", slug: "lich-hoi-thao-chuyen-doi-so-danh-cho-hoi-vien-thang-5",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], category_ids: ["cat-event"],
tagsearch_values: ["Sự kiện", "Đăng ký"], tagsearch_values: ["S? ki?n", "�ang k�"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -247,15 +280,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -247,15 +280,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-20T08:00", started_at: "2026-05-20T08:00",
ended_at: "2026-05-20T17:00", ended_at: "2026-05-20T17:00",
registration_deadline: "2026-05-18T17:00", registration_deadline: "2026-05-18T17:00",
location: "Trung tâm Hội nghị VCCI", location: "Trung t�m H?i ngh? VCCI",
participation_fee: "500.000 VNĐ", participation_fee: "500.000 VN",
post_content: [ post_content: [
{ {
id: "section-admin-news-02-a", id: "section-admin-news-02-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -264,10 +297,10 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -264,10 +297,10 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-03", 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", slug: "gioi-thieu-vai-tro-cua-vcci-news-trong-he-sinh-thai-noi-dung-so",
summary: 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", type: "baiviettrang",
header_category_id: "intro-about", header_category_id: "intro-about",
category_ids: [], category_ids: [],
...@@ -290,7 +323,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -290,7 +323,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -308,14 +341,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -308,14 +341,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-04", 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", slug: "dien-dan-xuc-tien-thuong-mai-khu-vuc-phia-nam-thu-hut-hon-300-doanh-nghiep-tham-du",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-news", "cat-activity"], 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, is_featured: true,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -326,15 +359,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -326,15 +359,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Trung tâm Hội chợ và Triển lãm Sài Gòn", location: "Trung t�m H?i ch? v� Tri?n l�m S�i G�n",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-04-a", id: "section-admin-news-04-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -343,14 +376,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -343,14 +376,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-05", 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", slug: "ban-tin-nhanh-doanh-nghiep-hoi-vien-tang-toc-chuyen-doi-so-trong-khau-ban-hang-va-cham-soc-khach-hang",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-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, is_featured: true,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -361,15 +394,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -361,15 +394,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Hệ thống trực tuyến", location: "H? th?ng tr?c tuy?n",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-05-a", id: "section-admin-news-05-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -378,14 +411,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -378,14 +411,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-06", 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", slug: "khoi-dong-chuoi-doi-thoai-chinh-sach-voi-doanh-nghiep-va-hiep-hoi-nganh-hang-nam-2026",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event", "cat-policy"], 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, is_featured: true,
thumbnail: toImageRef(mediaSeed[0]), thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false, is_hidden: false,
...@@ -396,15 +429,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -396,15 +429,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-28T08:30", started_at: "2026-05-28T08:30",
ended_at: "2026-05-28T12:00", ended_at: "2026-05-28T12:00",
registration_deadline: "2026-05-25T17:00", registration_deadline: "2026-05-25T17:00",
location: "Hà Nội", location: "H� N?i",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-06-a", id: "section-admin-news-06-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -413,14 +446,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -413,14 +446,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-07", 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", slug: "cam-nang-thiet-ke-gian-hang-trien-lam-hieu-qua-cho-doanh-nghiep-tham-gia-hoi-cho-quoc-te",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-news", "cat-activity"], category_ids: ["cat-news", "cat-activity"],
tagsearch_values: ["Chuyên Đề", "Cẩm nang"], tagsearch_values: ["Chuy�n �?", "C?m nang"],
is_featured: true, is_featured: true,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -431,15 +464,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -431,15 +464,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-07-a", id: "section-admin-news-07-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -448,14 +481,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -448,14 +481,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-08", 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", slug: "hoa-ky-muon-day-manh-hop-tac-kinh-te-thuong-mai-ben-vung-voi-viet-nam",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-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, is_featured: false,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -466,15 +499,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -466,15 +499,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-08-a", id: "section-admin-news-08-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -483,14 +516,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -483,14 +516,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-09", 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", slug: "nhung-diem-sang-trong-buc-tranh-kinh-te-so-lieu-quy-ii-va-6-thang-dau-nam-2026",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-news"], category_ids: ["cat-news"],
tagsearch_values: ["Tin Kinh Tế", "Vĩ mô"], tagsearch_values: ["Tin Kinh T?", "Vi m�"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -501,15 +534,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -501,15 +534,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Hà Nội", location: "H� N?i",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-09-a", id: "section-admin-news-09-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -518,14 +551,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -518,14 +551,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-10", 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", slug: "tinh-hinh-kinh-te-vi-mo-quy-1-nam-2026",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-news"], category_ids: ["cat-news"],
tagsearch_values: ["Tin Kinh Tế", "Vĩ mô"], tagsearch_values: ["Tin Kinh T?", "Vi m�"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[0]), thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false, is_hidden: false,
...@@ -536,15 +569,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -536,15 +569,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Hà Nội", location: "H� N?i",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-10-a", id: "section-admin-news-10-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -553,14 +586,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -553,14 +586,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-11", 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", slug: "cam-nang-huong-dan-dau-tu-kinh-doanh-tai-viet-nam-danh-cho-doanh-nghiep-moi",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-news"], category_ids: ["cat-news"],
tagsearch_values: ["Chuyên Đề", "Cẩm nang"], tagsearch_values: ["Chuy�n �?", "C?m nang"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -571,15 +604,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -571,15 +604,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Trực tuyến", location: "Tr?c tuy?n",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-11-a", id: "section-admin-news-11-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -588,14 +621,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -588,14 +621,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-12", 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", slug: "khoa-dao-tao-quan-tri-thue-va-phap-ly-trong-giao-dich",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], category_ids: ["cat-event"],
tagsearch_values: ["Đào tạo", "Sự kiện"], tagsearch_values: ["��o t?o", "S? ki?n"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -606,15 +639,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -606,15 +639,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-11-18T08:30", started_at: "2026-11-18T08:30",
ended_at: "2026-11-18T16:30", ended_at: "2026-11-18T16:30",
registration_deadline: "2026-11-15T17:00", registration_deadline: "2026-11-15T17:00",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "800.000 VNĐ", participation_fee: "800.000 VN",
post_content: [ post_content: [
{ {
id: "section-admin-news-12-a", id: "section-admin-news-12-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -623,14 +656,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -623,14 +656,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-13", 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", slug: "su-kien-tap-huan-nsdld",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], category_ids: ["cat-event"],
tagsearch_values: ["Đào tạo", "Sự kiện"], tagsearch_values: ["��o t?o", "S? ki?n"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -641,15 +674,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -641,15 +674,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-11-20T13:30", started_at: "2026-11-20T13:30",
ended_at: "2026-11-20T17:00", ended_at: "2026-11-20T17:00",
registration_deadline: "2026-11-18T17:00", registration_deadline: "2026-11-18T17:00",
location: "Hà Nội", location: "H� N?i",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-13-a", id: "section-admin-news-13-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -658,14 +691,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -658,14 +691,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-14", 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", slug: "dien-dan-hoi-vien-ket-noi-thi-truong-va-chuoi-cung-ung",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[0]), thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false, is_hidden: false,
...@@ -676,15 +709,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -676,15 +709,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-11-24T08:00", started_at: "2026-11-24T08:00",
ended_at: "2026-11-24T12:00", ended_at: "2026-11-24T12:00",
registration_deadline: "2026-11-22T17:00", registration_deadline: "2026-11-22T17:00",
location: "Đà Nẵng", location: "�� N?ng",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-14-a", id: "section-admin-news-14-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -693,14 +726,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -693,14 +726,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-15", 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", slug: "chuong-trinh-ket-noi-doanh-nghiep-hoi-vien-nganh-thuc-pham-va-ban-le",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -711,15 +744,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -711,15 +744,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-22T14:00", started_at: "2026-05-22T14:00",
ended_at: "2026-05-22T17:00", ended_at: "2026-05-22T17:00",
registration_deadline: "2026-05-20T17:00", registration_deadline: "2026-05-20T17:00",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-15-a", id: "section-admin-news-15-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -728,14 +761,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -728,14 +761,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-16", 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", slug: "lop-dao-tao-ngan-han-ky-nang-xay-dung-ke-hoach-xuc-tien-thuong-mai",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], category_ids: ["cat-event"],
tagsearch_values: ["Đào tạo", "Sự kiện"], tagsearch_values: ["��o t?o", "S? ki?n"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -746,15 +779,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -746,15 +779,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-26T08:30", started_at: "2026-05-26T08:30",
ended_at: "2026-05-26T11:30", ended_at: "2026-05-26T11:30",
registration_deadline: "2026-05-24T17:00", registration_deadline: "2026-05-24T17:00",
location: "Trực tuyến", location: "Tr?c tuy?n",
participation_fee: "350.000 VNĐ", participation_fee: "350.000 VN",
post_content: [ post_content: [
{ {
id: "section-admin-news-16-a", id: "section-admin-news-16-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -763,14 +796,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -763,14 +796,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-17", 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", slug: "cong-ty-western-coast-enterprise-ltd-can-thue-mua-vat-lieu-xay-dung-tai-viet-nam",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-business-opportunity"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -781,7 +814,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -781,7 +814,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "", participation_fee: "",
post_content: [ post_content: [
{ {
...@@ -789,7 +822,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -789,7 +822,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -798,14 +831,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -798,14 +831,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-18", 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", slug: "vcci-hcm-ket-noi-cac-nha-dau-tu-nuoc-ngoai-voi-doanh-nghiep-viet-trong-chuoi-cung-ung-cong-nghiep",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-business-opportunity"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -816,7 +849,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -816,7 +849,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Bình Dương", location: "B�nh Duong",
participation_fee: "", participation_fee: "",
post_content: [ post_content: [
{ {
...@@ -824,7 +857,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -824,7 +857,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -833,14 +866,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -833,14 +866,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-19", 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", slug: "doanh-nghiep-logistics-tim-doi-tac-phan-phoi-va-khai-thac-tuyen-van-chuyen-lien-vung",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-business-opportunity"], category_ids: ["cat-business-opportunity"],
tagsearch_values: ["Cơ hội kinh doanh", "Logistics"], tagsearch_values: ["Co h?i kinh doanh", "Logistics"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[0]), thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false, is_hidden: false,
...@@ -859,7 +892,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -859,7 +892,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -868,14 +901,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -868,14 +901,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-20", 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", slug: "nhung-chinh-sach-moi-co-hieu-luc-tu-ngay-01-10-2026",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-policy-law"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[0]), thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false, is_hidden: false,
...@@ -886,7 +919,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -886,7 +919,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Hà Nội", location: "H� N?i",
participation_fee: "", participation_fee: "",
post_content: [ post_content: [
{ {
...@@ -894,7 +927,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -894,7 +927,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -903,14 +936,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -903,14 +936,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-21", 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", slug: "luat-duoc-sua-doi-nhieu-quy-dinh-moi-co-loi-cho-doanh-nghiep",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-policy-law"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -921,7 +954,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -921,7 +954,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Hà Nội", location: "H� N?i",
participation_fee: "", participation_fee: "",
post_content: [ post_content: [
{ {
...@@ -929,7 +962,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -929,7 +962,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -938,14 +971,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -938,14 +971,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-22", 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", slug: "bao-ve-co-dong-co-duoc-coi-la-su-kien-bat-kha-khang-doi-voi-doanh-nghiep",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-policy-law"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -956,7 +989,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -956,7 +989,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "", participation_fee: "",
post_content: [ post_content: [
{ {
...@@ -964,7 +997,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -964,7 +997,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -973,14 +1006,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -973,14 +1006,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-23", 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", slug: "ket-noi-hoi-vien-nganh-xay-dung-va-vat-lieu-tai-khu-vuc-phia-nam",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-member-connection"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -991,7 +1024,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -991,7 +1024,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "", participation_fee: "",
post_content: [ post_content: [
{ {
...@@ -999,7 +1032,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -999,7 +1032,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -1008,14 +1041,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1008,14 +1041,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-24", 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", slug: "khong-gian-gap-go-hoi-vien-moi-trong-mang-luoi-doanh-nghiep-dich-vu",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-member-connection"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[0]), thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false, is_hidden: false,
...@@ -1026,7 +1059,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1026,7 +1059,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "", participation_fee: "",
post_content: [ post_content: [
{ {
...@@ -1034,7 +1067,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1034,7 +1067,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -1043,14 +1076,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1043,14 +1076,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-25", 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", slug: "chuong-trinh-chia-se-co-hoi-hop-tac-giua-hoi-vien-cong-nghe-va-doanh-nghiep-truyen-thong",
summary: 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", type: "tintuc",
header_category_id: "activity-news", header_category_id: "activity-news",
category_ids: ["cat-member-connection"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -1061,7 +1094,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1061,7 +1094,7 @@ const newsSeed: AdminNewsItem[] = [
started_at: "", started_at: "",
ended_at: "", ended_at: "",
registration_deadline: "", registration_deadline: "",
location: "Trực tuyến", location: "Tr?c tuy?n",
participation_fee: "", participation_fee: "",
post_content: [ post_content: [
{ {
...@@ -1069,7 +1102,7 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1069,7 +1102,7 @@ const newsSeed: AdminNewsItem[] = [
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -1078,14 +1111,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1078,14 +1111,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-26", 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", slug: "phien-ket-noi-nha-mua-hang-quoc-te-voi-doanh-nghiep-hoi-vien-nganh-go",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -1096,15 +1129,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1096,15 +1129,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-29T08:30", started_at: "2026-05-29T08:30",
ended_at: "2026-05-29T11:30", ended_at: "2026-05-29T11:30",
registration_deadline: "2026-05-27T17:00", registration_deadline: "2026-05-27T17:00",
location: "TP. Hồ Chí Minh", location: "TP. H? Ch� Minh",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-26-a", id: "section-admin-news-26-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -1113,14 +1146,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1113,14 +1146,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-27", 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", slug: "hoi-thao-chuyen-de-logistics-lanh-cho-doanh-nghiep-thuc-pham-va-nong-san",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], category_ids: ["cat-event"],
tagsearch_values: ["Sự kiện", "Chuyên đề"], tagsearch_values: ["S? ki?n", "Chuy�n d?"],
is_featured: false, is_featured: false,
thumbnail: toImageRef(mediaSeed[0]), thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false, is_hidden: false,
...@@ -1131,15 +1164,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1131,15 +1164,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-05-30T13:30", started_at: "2026-05-30T13:30",
ended_at: "2026-05-30T16:30", ended_at: "2026-05-30T16:30",
registration_deadline: "2026-05-28T17:00", registration_deadline: "2026-05-28T17:00",
location: "Cần Thơ", location: "C?n Tho",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-27-a", id: "section-admin-news-27-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -1148,14 +1181,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1148,14 +1181,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-28", 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", slug: "khoa-dao-tao-thuc-hanh-xay-dung-ho-so-nang-luc-so-cho-doanh-nghiep-hoi-vien",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[3]), thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false, is_hidden: false,
...@@ -1166,15 +1199,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1166,15 +1199,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-06-03T08:30", started_at: "2026-06-03T08:30",
ended_at: "2026-06-03T11:30", ended_at: "2026-06-03T11:30",
registration_deadline: "2026-06-01T17:00", registration_deadline: "2026-06-01T17:00",
location: "Trực tuyến", location: "Tr?c tuy?n",
participation_fee: "300.000 VNĐ", participation_fee: "300.000 VN",
post_content: [ post_content: [
{ {
id: "section-admin-news-28-a", id: "section-admin-news-28-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -1183,14 +1216,14 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1183,14 +1216,14 @@ const newsSeed: AdminNewsItem[] = [
}, },
{ {
id: "admin-news-29", 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", slug: "dien-dan-ket-noi-tai-chinh-xanh-cho-doanh-nghiep-san-xuat-va-xuat-khau",
summary: 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", type: "tintuc",
header_category_id: "activity-events", header_category_id: "activity-events",
category_ids: ["cat-event"], 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, is_featured: false,
thumbnail: toImageRef(mediaSeed[2]), thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false, is_hidden: false,
...@@ -1201,15 +1234,15 @@ const newsSeed: AdminNewsItem[] = [ ...@@ -1201,15 +1234,15 @@ const newsSeed: AdminNewsItem[] = [
started_at: "2026-06-05T14:00", started_at: "2026-06-05T14:00",
ended_at: "2026-06-05T17:00", ended_at: "2026-06-05T17:00",
registration_deadline: "2026-06-03T17:00", registration_deadline: "2026-06-03T17:00",
location: "Hà Nội", location: "H� N?i",
participation_fee: "Miễn phí", participation_fee: "Mi?n ph�",
post_content: [ post_content: [
{ {
id: "section-admin-news-29-a", id: "section-admin-news-29-a",
type: "text", type: "text",
position: 1, position: 1,
content: 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_columns: 2,
image_rows: 2, image_rows: 2,
images: [], images: [],
...@@ -1222,8 +1255,8 @@ export function slugifyAdminNews(value: string) { ...@@ -1222,8 +1255,8 @@ export function slugifyAdminNews(value: string) {
return value return value
.normalize("NFD") .normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") .replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d") .replace(/d/g, "d")
.replace(/Đ/g, "D") .replace(//g, "D")
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/[^a-z0-9\s-]/g, "") .replace(/[^a-z0-9\s-]/g, "")
...@@ -1308,12 +1341,14 @@ export function cloneAdminNewsFormValues(item?: AdminNewsItem | null): AdminNews ...@@ -1308,12 +1341,14 @@ export function cloneAdminNewsFormValues(item?: AdminNewsItem | null): AdminNews
} }
export function normalizeAdminMediaItems(items: AdminMediaItem[]) { export function normalizeAdminMediaItems(items: AdminMediaItem[]) {
return [...items].sort((left, right) => { return items
return ( .map((item) => normalizeSeedMediaLabels(item))
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime() || .sort((left, right) => {
right.name.localeCompare(left.name, "vi") return (
); new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime() ||
}); right.name.localeCompare(left.name, "vi")
);
});
} }
export function normalizeAdminNewsItems(items: AdminNewsItem[]) { export function normalizeAdminNewsItems(items: AdminNewsItem[]) {
...@@ -1354,7 +1389,18 @@ export function readAdminMediaItems() { ...@@ -1354,7 +1389,18 @@ export function readAdminMediaItems() {
return getAdminMediaSeed(); 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 { } catch {
return getAdminMediaSeed(); return getAdminMediaSeed();
} }
......
...@@ -257,7 +257,7 @@ export const categoryFallbackRows: Category[] = [ ...@@ -257,7 +257,7 @@ export const categoryFallbackRows: Category[] = [
id: "142c9525-b206-4b87-8978-4b7048a46a3b", id: "142c9525-b206-4b87-8978-4b7048a46a3b",
name: "Trang chủ", name: "Trang chủ",
slug: "trang-chu", slug: "trang-chu",
url: "/trang-chu", url: "/",
sort_order: 1, sort_order: 1,
created_at: "2026-05-14T04:54:26.127Z", created_at: "2026-05-14T04:54:26.127Z",
created_by: null, created_by: null,
......
import { create } from 'zustand' import { create } from "zustand";
import { devtools, persist } from 'zustand/middleware' 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 { export interface AuthStoreStateType {
// States appIsLoggedIn: boolean;
appIsLoggedIn: boolean appAccessToken: string | null;
appAccessToken: string | null appAccessTokenExpired: number | null;
appAccessTokenExpired: number | null appRefreshToken: string | null;
appRefreshToken: string | null appSession: AuthenticatedAdminSession | null;
appUserRemember: { appUser: AuthenticatedAdminUser | null;
username: string appIsRefreshing: boolean;
password: string appSessionExpiredNotified: boolean;
remember: boolean appUserRemember: {
} | null username: string;
_hasHydrated: boolean password: string;
remember: boolean;
// Actions } | null;
setHasHydrated: (state: AuthStoreStateType) => void _hasHydrated: boolean;
setAppIsLoggedIn: (isLoggedIn: boolean) => void setHasHydrated: (state: AuthStoreStateType) => void;
setAppToken: (accessToken: string, accessTokenExpired: number, refreshToken?: string) => void setAppIsLoggedIn: (isLoggedIn: boolean) => void;
removeAppToken: () => void setAuthSession: (payload: AuthSessionPayload) => void;
setAppUserRemember: (username: string, password: string, remember: boolean) => void updateAccessToken: (payload: AuthRefreshPayload) => void;
resetStore: () => void setAppUser: (user: AuthenticatedAdminUser | null) => void;
setAppToken: (accessToken: string, accessTokenExpired: number, refreshToken?: string) => void;
} setAppRefreshing: (isRefreshing: boolean) => void;
markSessionExpiredNotified: (notified: boolean) => void;
// Define store removeAppToken: () => void;
const useAuthStore = create<AuthStoreStateType>()( setAppUserRemember: (username: string, password: string, remember: boolean) => void;
devtools( resetStore: () => void;
persist( }
(set, get) => ({
// States const getAccessTokenExpiredAt = (expiresIn: number) =>
appIsLoggedIn: false, Date.now() + Math.max(expiresIn - 300, 0) * 1000;
appAccessToken: null,
appAccessTokenExpired: null, const baseState = {
appRefreshToken: null, appIsLoggedIn: false,
appUserRemember: null, appAccessToken: null,
appAccessTokenExpired: null,
// Methods appRefreshToken: null,
setAppIsLoggedIn: (isLoggedIn: boolean) => set(() => ({ appIsLoggedIn: isLoggedIn })), 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) => setAppToken: (accessToken: string, accessTokenExpired: number, refreshToken?: string) =>
set(() => ({ set(() => ({
appIsLoggedIn: true, appIsLoggedIn: true,
appAccessToken: accessToken, appAccessToken: accessToken,
appAccessTokenExpired: Date.now() + Math.max(accessTokenExpired - 300, 0) * 1000, appAccessTokenExpired: getAccessTokenExpiredAt(accessTokenExpired),
appRefreshToken: refreshToken ?? get().appRefreshToken appRefreshToken: refreshToken ?? get().appRefreshToken,
appIsRefreshing: false,
})),
setAppRefreshing: (isRefreshing: boolean) =>
set(() => ({
appIsRefreshing: isRefreshing,
})),
markSessionExpiredNotified: (notified: boolean) =>
set(() => ({
appSessionExpiredNotified: notified,
})), })),
removeAppToken: () => { removeAppToken: () => {
set(() => ({ set(() => ({
appIsLoggedIn: false, ...clearSessionState,
appAccessToken: null, appUserRemember: get().appUserRemember,
appAccessTokenExpired: null, _hasHydrated: get()._hasHydrated,
appRefreshToken: null }));
})) },
}, setAppUserRemember: (username, password, remember) =>
setAppUserRemember: (username, password, remember) => set(() => ({
set(() => ({ appUserRemember: {
appUserRemember: { username,
username, password,
password, remember,
remember },
} })),
})),
resetStore: () => { resetStore: () => {
// Clear in-memory state const rememberedUser = get().appUserRemember;
set(() => ({ set(() => ({
appIsLoggedIn: false, ...clearSessionState,
appAccessToken: null, appUserRemember: rememberedUser,
appAccessTokenExpired: null, _hasHydrated: true,
appRefreshToken: null, }));
appUserRemember: null,
_hasHydrated: true try {
})) localStorage.removeItem("app-auth-storage");
} catch {
// Remove persisted storage // ignore
try { }
localStorage.removeItem('app-auth-storage') },
} catch { }),
// ignore {
} name: "app-auth-storage",
}, partialize: (state) => ({
_hasHydrated: false, appIsLoggedIn: state.appIsLoggedIn,
setHasHydrated: (state: AuthStoreStateType) => appAccessToken: state.appAccessToken,
set(() => ({ appAccessTokenExpired: state.appAccessTokenExpired,
_hasHydrated: state != undefined appRefreshToken: state.appRefreshToken,
})) appSession: state.appSession,
}), appUser: state.appUser,
{ appSessionExpiredNotified: state.appSessionExpiredNotified,
name: 'app-auth-storage', appUserRemember: state.appUserRemember,
onRehydrateStorage: () => { }),
return (state: AuthStoreStateType | undefined, error: unknown) => { onRehydrateStorage: () => {
if (error || state == undefined) return return (state: AuthStoreStateType | undefined, error: unknown) => {
if (error || state == undefined) return;
state.setHasHydrated(state) state.setHasHydrated(state);
return };
} },
} },
} ),
) ),
) );
)
export default useAuthStore;
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