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

fix

parent d04f19c2
// Core
import { AxiosError, isAxiosError } from 'axios'
import { QueryClient } from '@tanstack/react-query'
// App
// import router from '@/router'
import useAuthStore from '@/store/useAuthStore'
// import useProfileStore from '@stores/profile'
import { QueryData } from '@/lib/types/base-api'
import { AxiosError, isAxiosError } from 'axios'
import { QueryClient } from '@tanstack/react-query'
// App
// import router from '@/router'
import { handleAdminUnauthorized } from '@/lib/auth/admin-auth'
// import useProfileStore from '@stores/profile'
import { QueryData } from '@/lib/types/base-api'
// import { BASE_PATHS } from '@/constants/path'
// Constants
......@@ -40,16 +40,15 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
}
// Handle un authorization error
const handleUnAuthorizationError = () => {
useAuthStore.getState().resetStore()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// })
// router.navigate('')
window.location.href = process ? '/' : '/admin'
}
const handleUnAuthorizationError = () => {
void handleAdminUnauthorized()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// })
// router.navigate('')
}
// Handle delay value
const handleDelayRetry = (failureCount: number) => failureCount * 1000 + Math.random() * 1000
......
import Axios, { AxiosError, AxiosRequestConfig } from "axios";
import Axios, { AxiosError, AxiosHeaders, AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
import {
ensureValidAdminAccessToken,
refreshAdminAccessToken,
} from "@/lib/auth/admin-auth";
interface RetriableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
const createAxiosInstance = () => {
const instance = Axios.create({
......@@ -6,11 +14,17 @@ const createAxiosInstance = () => {
withCredentials: true,
});
instance.interceptors.request.use((config) => {
const token = getPersistedAccessToken();
instance.interceptors.request.use(async (config) => {
if (shouldSkipAuthHandling(config.url)) {
return config;
}
const token = await ensureValidAdminAccessToken().catch(() => null);
if (token && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${token}`;
if (token) {
const headers = AxiosHeaders.from(config.headers);
headers.set("Authorization", `Bearer ${token}`);
config.headers = headers;
}
return config;
......@@ -18,7 +32,36 @@ const createAxiosInstance = () => {
instance.interceptors.response.use(
async (response) => response,
(error) => Promise.reject(error),
async (error: AxiosError) => {
const originalRequest = error.config as RetriableAxiosRequestConfig | undefined;
if (
error.response?.status !== 401 ||
!originalRequest ||
originalRequest._retry ||
shouldSkipAuthHandling(originalRequest.url)
) {
return Promise.reject(error);
}
originalRequest._retry = true;
try {
const nextAccessToken = await refreshAdminAccessToken();
if (!nextAccessToken) {
return Promise.reject(error);
}
const headers = AxiosHeaders.from(originalRequest.headers);
headers.set("Authorization", `Bearer ${nextAccessToken}`);
originalRequest.headers = headers;
return instance(originalRequest);
} catch (refreshError) {
return Promise.reject(refreshError);
}
},
);
return instance;
......@@ -26,23 +69,9 @@ const createAxiosInstance = () => {
const AXIOS_INSTANCE = createAxiosInstance();
const getPersistedAccessToken = () => {
if (typeof window === "undefined") return null;
try {
const rawAuthStorage = window.localStorage.getItem("app-auth-storage");
if (!rawAuthStorage) return null;
const parsedAuthStorage = JSON.parse(rawAuthStorage) as {
state?: {
appAccessToken?: string | null;
};
};
return parsedAuthStorage.state?.appAccessToken ?? null;
} catch {
return null;
}
const shouldSkipAuthHandling = (url?: string | null) => {
if (!url) return false;
return /\/auth\/(login|refresh|logout)(\?|$)/.test(url);
};
const convertHeaders = (headers?: HeadersInit): Record<string, string> | undefined => {
......
'use client';
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
const businessItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
!item.is_hidden &&
(item.category_ids.includes("cat-business-opportunity") ||
item.tagsearch_values.some((tag) => tag.toLowerCase().includes("cơ hội kinh doanh"))),
)
.sort(
(left, right) =>
new Date(right.published_at || right.created_at).getTime() -
new Date(left.published_at || left.created_at).getTime(),
);
function formatPublishDate(item: AdminNewsItem) {
return dayjs(item.published_at || item.created_at).format("DD/MM/YYYY");
}
function BusinessOpportunities() {
const { businessPosts, categoryLinks, categoryNames } = useHomePosts();
const businessItems = businessPosts;
const [featuredItem, ...listItems] = businessItems;
if (!featuredItem) return null;
const listSlots = Array.from({ length: 3 }, (_, index) => listItems[index] ?? null);
const sectionLink =
categoryLinks.get(categoryNames.coHoiKinhDoanh.toLowerCase()) ??
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh";
return (
<section className="flex-1">
......@@ -42,7 +25,7 @@ function BusinessOpportunities() {
</div>
<Link
href="/xuc-tien-thuong-mai/co-hoi/"
href={sectionLink}
className="text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<ChevronRight className="h-5 w-5" />
......@@ -50,32 +33,56 @@ function BusinessOpportunities() {
</div>
<div className="space-y-3">
<Link
href="/xuc-tien-thuong-mai/co-hoi/"
className="block rounded-[18px] bg-[#f5f7fb] px-4 py-3.5 transition-colors hover:bg-[#eef3fb]"
>
<h3 className="line-clamp-2 text-[16px] font-bold leading-[1.45] text-[#264798] md:text-[17px]">
{featuredItem.title}
</h3>
<p className="mt-2 text-[13px] text-[#9aa8c1]">{formatPublishDate(featuredItem)}</p>
</Link>
{featuredItem ? (
<Link
href={featuredItem.externalLink}
className="block rounded-[18px] bg-[#f5f7fb] px-4 py-3.5 transition-colors hover:bg-[#eef3fb]"
>
<h3 className="line-clamp-2 text-[16px] font-bold leading-[1.45] text-[#264798] md:text-[17px]">
{featuredItem.title}
</h3>
<p className="mt-2 text-[13px] text-[#9aa8c1]">
{dayjs(featuredItem.publishedAt || featuredItem.createdAt).format("DD/MM/YYYY")}
</p>
</Link>
) : (
<div className="rounded-[18px] bg-[#f5f7fb] px-4 py-3.5">
<div className="h-6 w-5/6 rounded bg-white" />
<div className="mt-2 h-4 w-24 rounded bg-white/80" />
</div>
)}
<div className="space-y-2.5">
{listItems.slice(0, 3).map((item) => (
<Link
key={item.id}
href="/xuc-tien-thuong-mai/co-hoi/"
className="flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798]">
{item.title}
</h4>
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">{formatPublishDate(item)}</p>
{listSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className="flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798]">
{item.title}
</h4>
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">
{dayjs(item.publishedAt || item.createdAt).format("DD/MM/YYYY")}
</p>
</div>
</Link>
) : (
<div
key={`business-placeholder-${index}`}
className="flex gap-3 rounded-[14px] px-0.5 py-1"
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-[#eef3fb]" />
<div className="mt-1.5 h-4 w-24 rounded bg-[#f4f7fb]" />
</div>
</div>
</Link>
))}
),
)}
</div>
</div>
</section>
......
'use client';
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { useHomePosts, type HomePostItem } from "@/app/(main)/(home)/lib/use-home-posts";
import { addMonths, format, getDay, startOfMonth, subMonths } from "date-fns";
import dayjs from "dayjs";
import { ChevronLeft, ChevronRight } from "lucide-react";
......@@ -11,41 +8,32 @@ import { useMemo, useState } from "react";
const weekDays = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
const eventItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
!item.is_hidden &&
item.started_at,
)
.sort(
(left, right) =>
new Date(left.started_at).getTime() - new Date(right.started_at).getTime(),
);
function isTrainingEvent(item: AdminNewsItem) {
return item.tagsearch_values.some((tag) => tag.toLowerCase().includes("đào tạo"));
}
function EventsCalendar() {
const firstEventDate = eventItems[0]?.started_at
? new Date(eventItems[0].started_at)
const { eventPosts } = useHomePosts();
const firstEventDate = eventPosts[0]?.startedAt
? new Date(eventPosts[0].startedAt)
: new Date("2026-11-01T00:00:00");
const [currentMonth, setCurrentMonth] = useState(
new Date(firstEventDate.getFullYear(), firstEventDate.getMonth(), 1),
);
const isTrainingEvent = (item: HomePostItem) =>
item.categories.some((category) =>
category.name.toLowerCase().includes("đào tạo"),
);
const monthEvents = useMemo(
() =>
eventItems.filter((item) => {
const date = new Date(item.started_at);
eventPosts.filter((item) => {
const date = new Date(item.startedAt);
return (
date.getMonth() === currentMonth.getMonth() &&
date.getFullYear() === currentMonth.getFullYear()
);
}),
[currentMonth],
[currentMonth, eventPosts],
);
const days = useMemo(() => {
......@@ -62,10 +50,10 @@ function EventsCalendar() {
}, [currentMonth]);
const eventMap = useMemo(() => {
const map = new Map<string, AdminNewsItem[]>();
const map = new Map<string, HomePostItem[]>();
monthEvents.forEach((item) => {
const key = dayjs(item.started_at).format("YYYY-MM-DD");
const key = dayjs(item.startedAt).format("YYYY-MM-DD");
const existing = map.get(key) ?? [];
existing.push(item);
map.set(key, existing);
......
'use client';
import ImageNext from "@/components/shared/image-next";
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import Link from "next/link";
const eventItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
item.header_category_id === "activity-events" &&
!item.is_hidden &&
item.started_at,
)
.sort(
(left, right) =>
new Date(left.started_at).getTime() - new Date(right.started_at).getTime(),
);
function formatEventDate(item: AdminNewsItem) {
return dayjs(item.started_at || item.published_at || item.created_at).format("DD/MM/YYYY");
}
function Events() {
const { eventPosts, categoryLinks, categoryNames } = useHomePosts();
const eventItems = eventPosts;
const [featuredEvent, ...sideEvents] = eventItems;
if (!featuredEvent) return null;
const sideSlots = Array.from({ length: 4 }, (_, index) => sideEvents[index] ?? null);
const eventsLink =
categoryLinks.get(categoryNames.suKien.toLowerCase()) ?? "/hoat-dong/su-kien";
return (
<div className="flex-1 rounded-[28px] bg-linear-to-br from-[#14488f] to-[#2d67bf] p-4 text-white shadow-[0_18px_38px_rgba(16,61,130,0.24)] md:p-5">
......@@ -41,7 +24,7 @@ function Events() {
</div>
<Link
href="/hoat-dong/su-kien"
href={eventsLink}
className="pt-1.5 text-sm font-semibold text-[#ffd34f] transition-colors hover:text-white"
>
Xem sự kiện
......@@ -49,53 +32,84 @@ function Events() {
</div>
<div className="grid items-stretch gap-3 xl:grid-cols-[minmax(0,1.02fr)_minmax(270px,0.98fr)]">
<Link
href="/hoat-dong/su-kien"
className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
>
<div className="h-[220px] overflow-hidden md:h-[235px] xl:h-[248px]">
<ImageNext
src={featuredEvent.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720}
height={520}
className="h-full w-full object-cover"
/>
</div>
{featuredEvent ? (
<Link
href={featuredEvent.externalLink}
className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
>
<div className="h-[220px] overflow-hidden md:h-[235px] xl:h-[248px]">
<ImageNext
src={featuredEvent.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720}
height={520}
className="h-full w-full object-cover"
/>
</div>
<div className="p-3 pt-2.5">
<h3 className="line-clamp-2 text-[16px] font-extrabold uppercase leading-[1.28] text-[#22459b] md:text-[18px]">
{featuredEvent.title}
</h3>
<p className="mt-1.5 text-[13px] text-[#90a0bd]">{formatEventDate(featuredEvent)}</p>
<div className="p-3 pt-2.5">
<h3 className="line-clamp-2 text-[16px] font-extrabold uppercase leading-[1.28] text-[#22459b] md:text-[18px]">
{featuredEvent.title}
</h3>
<p className="mt-1.5 text-[13px] text-[#90a0bd]">
{dayjs(
featuredEvent.startedAt || featuredEvent.publishedAt || featuredEvent.createdAt,
).format("DD/MM/YYYY")}
</p>
</div>
</Link>
) : (
<div className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.12)]">
<div className="h-[220px] bg-[#d7e3f9] md:h-[235px] xl:h-[248px]" />
<div className="space-y-2 p-3 pt-2.5">
<div className="h-6 w-5/6 rounded bg-[#e7eefb]" />
<div className="h-4 w-24 rounded bg-[#eef3fb]" />
</div>
</div>
</Link>
)}
<div className="flex h-full flex-col gap-3">
{sideEvents.slice(0, 4).map((item) => (
<Link
key={item.id}
href="/hoat-dong/su-kien"
className="flex flex-1 items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
>
<div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[12px]">
<ImageNext
src={item.thumbnail?.url ?? "/thumbnail.png"}
alt={item.thumbnail?.alt || item.title}
width={160}
height={160}
className="h-full w-full object-cover"
/>
</div>
{sideSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className="flex flex-1 items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
>
<div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[12px]">
<ImageNext
src={item.thumbnail?.url ?? "/thumbnail.png"}
alt={item.thumbnail?.alt || item.title}
width={160}
height={160}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] font-semibold leading-[1.35] text-white">
{item.title}
</h4>
<p className="mt-1 text-[12px] text-white/78">{formatEventDate(item)}</p>
<div className="min-w-0">
<h4 className="line-clamp-2 text-[15px] font-semibold leading-[1.35] text-white">
{item.title}
</h4>
<p className="mt-1 text-[12px] text-white/78">
{dayjs(item.startedAt || item.publishedAt || item.createdAt).format(
"DD/MM/YYYY",
)}
</p>
</div>
</Link>
) : (
<div
key={`event-placeholder-${index}`}
className="flex flex-1 items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]"
>
<div className="h-[64px] w-[64px] shrink-0 rounded-[12px] bg-white/20" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-white/25" />
<div className="mt-2 h-3 w-20 rounded bg-white/20" />
</div>
</div>
</Link>
))}
),
)}
</div>
</div>
</div>
......
'use client';
import ImageNext from "@/components/shared/image-next";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import memberImages from "@/constants/memberImages";
import {
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import Link from "next/link";
const memberConnectionItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
!item.is_hidden &&
(item.category_ids.includes("cat-member-connection") ||
item.tagsearch_values.some((tag) => tag.toLowerCase().includes("kết nối hội viên"))),
)
.sort(
(left, right) =>
new Date(right.published_at || right.created_at).getTime() -
new Date(left.published_at || left.created_at).getTime(),
);
function Members() {
const featuredConnection = memberConnectionItems[0];
const { memberConnectionPosts, categoryLinks, categoryNames } = useHomePosts();
const featuredConnection = memberConnectionPosts[0];
const sectionLink =
categoryLinks.get(categoryNames.ketNoiHoiVien.toLowerCase()) ?? "/hoi-vien/ket-noi-hoi-vien";
return (
<section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch">
......@@ -36,7 +23,7 @@ function Members() {
</div>
<Link
href="/danh-ba-hoi-vien"
href={sectionLink}
className="pt-1 text-sm font-semibold text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
Xem thêm
......@@ -77,7 +64,7 @@ function Members() {
{featuredConnection ? (
<Link
href="/danh-ba-hoi-vien"
href={featuredConnection.externalLink}
className="block overflow-hidden rounded-[20px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[1.25/1] overflow-hidden rounded-[20px]">
......@@ -90,7 +77,11 @@ function Members() {
/>
</div>
</Link>
) : null}
) : (
<div className="overflow-hidden rounded-[20px] bg-[#eef3fb] shadow-[0_16px_32px_rgba(31,59,124,0.08)]">
<div className="aspect-[1.25/1] rounded-[20px] bg-[#e3ebf8]" />
</div>
)}
</aside>
</section>
);
......
'use client';
import ImageNext from "@/components/shared/image-next";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import stripImagesAndHtml from "@/helpers/stripImageAndHtml";
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import dayjs from "dayjs";
import Link from "next/link";
import { useMemo, useState } from "react";
......@@ -13,60 +10,32 @@ import { useMemo, useState } from "react";
const tabs = [
{ id: "all", label: "Tất cả" },
{ id: "tin-vcci", label: "Tin VCCI" },
{ id: "tin-kinh-te", label: "Tin Kinh Tế" },
{ id: "chuyen-de", label: "Chuyên Đề" },
{ id: "tin-kinh-te", label: "Tin Kinh tế" },
{ id: "chuyen-de", label: "Chuyên đề" },
];
const allNewsItems = getAdminNewsSeed().filter(
(item) => item.type === "tintuc" && !item.is_hidden,
);
function getTabLabel(item: AdminNewsItem) {
const tags = item.tagsearch_values.map((tag) => tag.toLowerCase());
if (tags.some((tag) => tag.includes("kinh tế") || tag.includes("vĩ mô"))) {
return "Tin Kinh Tế";
}
if (tags.some((tag) => tag.includes("chuyên đề") || tag.includes("cẩm nang"))) {
return "Chuyên Đề";
}
return "Tin VCCI";
}
function matchesTab(item: AdminNewsItem, tab: string) {
if (tab === "all") return true;
const tags = item.tagsearch_values.map((value) => value.toLowerCase());
if (tab === "tin-vcci") {
return tags.some((tag) => tag.includes("tin vcci") || tag.includes("hợp tác"));
}
if (tab === "tin-kinh-te") {
return tags.some((tag) => tag.includes("kinh tế") || tag.includes("vĩ mô"));
}
if (tab === "chuyen-de") {
return tags.some((tag) => tag.includes("chuyên đề") || tag.includes("cẩm nang"));
}
return true;
}
function News() {
const [tab, setTab] = useState("all");
const { newsTabs, categoryLinks, categoryNames } = useHomePosts();
const filteredItems = useMemo(
() => allNewsItems.filter((item) => matchesTab(item, tab)),
[tab],
);
const filteredItems = useMemo(() => {
if (tab === "all") return newsTabs.all;
if (tab === "tin-kinh-te") return newsTabs.tinKinhTe;
if (tab === "chuyen-de") return newsTabs.chuyenDe;
return newsTabs.tinVcci;
}, [newsTabs, tab]);
const featuredArticle = filteredItems[0] ?? allNewsItems[0];
const featuredArticle = filteredItems[0] ?? newsTabs.all[0];
const listArticles = filteredItems.slice(1, 5);
if (!featuredArticle) return null;
const listSlots = Array.from({ length: 4 }, (_, index) => listArticles[index] ?? null);
const overviewLink =
(tab === "all"
? categoryLinks.get(categoryNames.tinVcci.toLowerCase())
: tab === "tin-kinh-te"
? categoryLinks.get(categoryNames.tinKinhTe.toLowerCase())
: tab === "chuyen-de"
? categoryLinks.get(categoryNames.chuyenDe.toLowerCase())
: categoryLinks.get(categoryNames.tinVcci.toLowerCase())) ?? "/hoat-dong/tin-tuc";
return (
<div className="flex-1">
......@@ -102,59 +71,93 @@ function News() {
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.02fr)_minmax(320px,0.98fr)]">
<div>
<Link
href="/hoat-dong/tin-tuc"
className="block h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="aspect-[1.75/1] overflow-hidden">
<ImageNext
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
height={580}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1.5 p-3">
<span className="inline-flex text-[14px] font-bold text-[#e2a500]">
{getTabLabel(featuredArticle)}
</span>
<h3 className="line-clamp-2 text-[16px] font-bold leading-[1.28] text-[#20408f] md:text-[17px]">
{featuredArticle.title}
</h3>
<p className="line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]">
{stripImagesAndHtml(featuredArticle.summary)}
</p>
{featuredArticle ? (
<Link
href={featuredArticle.externalLink}
className="block h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="aspect-[1.75/1] overflow-hidden">
<ImageNext
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
height={580}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1.5 p-3">
<span className="inline-flex text-[14px] font-bold text-[#e2a500]">
{featuredArticle.categories[0]?.name || "Tin tức"}
</span>
<h3 className="line-clamp-2 text-[16px] font-bold leading-[1.28] text-[#20408f] md:text-[17px]">
{featuredArticle.title}
</h3>
<p className="line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]">
{stripImagesAndHtml(featuredArticle.summary)}
</p>
<p className="text-[14px] text-[#8a9bb6]">
{dayjs(featuredArticle.published_at || featuredArticle.created_at).format("DD/MM/YYYY")}
</p>
<p className="text-[14px] text-[#8a9bb6]">
{dayjs(featuredArticle.publishedAt || featuredArticle.createdAt).format(
"DD/MM/YYYY",
)}
</p>
</div>
</Link>
) : (
<div className="h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]">
<div className="aspect-[1.75/1] bg-[#eef3fb]" />
<div className="space-y-2 p-3">
<div className="h-5 w-24 rounded bg-[#eef3fb]" />
<div className="h-6 w-5/6 rounded bg-[#eef3fb]" />
<div className="h-4 w-full rounded bg-[#f4f7fb]" />
<div className="h-4 w-3/4 rounded bg-[#f4f7fb]" />
<div className="h-4 w-24 rounded bg-[#eef3fb]" />
</div>
</div>
</Link>
)}
</div>
<div className="xl:flex xl:h-full xl:flex-col">
<div className="space-y-3 xl:flex xl:flex-1 xl:flex-col">
{listArticles.map((news) => (
<Link
key={news.id}
href="/hoat-dong/tin-tuc"
className="block rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)] xl:flex-1"
>
<h4 className="line-clamp-2 text-[15px] font-bold leading-[1.28] text-[#21408f]">
{news.title}
</h4>
<p className="mt-1 text-[13px] text-[#8a9bb6]">
{dayjs(news.published_at || news.created_at).format("DD/MM/YYYY")}
</p>
</Link>
))}
{listSlots.map((news, index) =>
news ? (
<Link
key={news.id}
href={news.externalLink}
className="block rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)] xl:flex-1"
>
<h4 className="line-clamp-2 text-[15px] font-bold leading-[1.28] text-[#21408f]">
{news.title}
</h4>
<p className="mt-1 text-[13px] text-[#8a9bb6]">
{dayjs(news.publishedAt || news.createdAt).format("DD/MM/YYYY")}
</p>
</Link>
) : (
<div
key={`news-placeholder-${index}`}
className="rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.06)] xl:flex-1"
>
<div className="h-5 w-5/6 rounded bg-[#eef3fb]" />
<div className="mt-2 h-4 w-24 rounded bg-[#f4f7fb]" />
</div>
),
)}
</div>
</div>
</div>
<div className="mt-3 flex justify-end">
<Link
href={overviewLink}
className="text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
Xem tất cả
</Link>
</div>
</div>
);
}
......
'use client';
import {
type AdminNewsItem,
getAdminNewsSeed,
} from "@/mockdata/admin-news";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
const policyItems = getAdminNewsSeed()
.filter(
(item) =>
item.type === "tintuc" &&
!item.is_hidden &&
(item.category_ids.includes("cat-policy-law") ||
item.category_ids.includes("cat-policy") ||
item.tagsearch_values.some((tag) => {
const normalized = tag.toLowerCase();
return normalized.includes("chính sách") || normalized.includes("pháp luật");
})),
)
.sort(
(left, right) =>
new Date(right.published_at || right.created_at).getTime() -
new Date(left.published_at || left.created_at).getTime(),
);
function formatPublishDate(item: AdminNewsItem) {
return dayjs(item.published_at || item.created_at).format("DD/MM/YYYY");
}
function PolicyAndLaws() {
const { policyPosts, categoryLinks, categoryNames } = useHomePosts();
const policyItems = policyPosts;
const [featuredItem, ...listItems] = policyItems;
if (!featuredItem) return null;
const listSlots = [featuredItem, ...listItems.slice(0, 2)];
const sectionLink =
categoryLinks.get(categoryNames.chinhSachPhapLuat.toLowerCase()) ??
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat";
return (
<section className="flex-1">
......@@ -46,7 +25,7 @@ function PolicyAndLaws() {
</div>
<Link
href="/thong-tin-truyen-thong/phap-luat"
href={sectionLink}
className="text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<ChevronRight className="h-5 w-5" />
......@@ -54,23 +33,38 @@ function PolicyAndLaws() {
</div>
<div className="space-y-2.5">
{[featuredItem, ...listItems.slice(0, 2)].map((item, index) => (
<Link
key={item.id}
href="/thong-tin-truyen-thong/phap-luat"
className={`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
index === 0 ? "pt-0.5" : ""
}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
<h3 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798] md:text-[16px]">
{item.title}
</h3>
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">{formatPublishDate(item)}</p>
{listSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className={`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
index === 0 ? "pt-0.5" : ""
}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
<h3 className="line-clamp-2 text-[15px] leading-[1.45] text-[#264798] md:text-[16px]">
{item.title}
</h3>
<p className="mt-1.5 text-[13px] text-[#9aa8c1]">
{dayjs(item.publishedAt || item.createdAt).format("DD/MM/YYYY")}
</p>
</div>
</Link>
) : (
<div
key={`policy-placeholder-${index}`}
className={`flex gap-3 rounded-[14px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-[#eef3fb]" />
<div className="mt-1.5 h-4 w-24 rounded bg-[#f4f7fb]" />
</div>
</div>
</Link>
))}
),
)}
</div>
</section>
);
......
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useCustomClient } from "@/api/mutator/custom-client";
import Links from "@/links";
type RawHomeCategory = {
id?: string | null;
name?: string | null;
url?: string | null;
type?: string | null;
};
type RawHomeThumbnail = {
path?: string | null;
original?: string | null;
};
type RawHomePost = {
id?: string | null;
title?: string | null;
external_link?: string | null;
summary?: string | null;
content?: string | null;
release_at?: string | null;
published_at?: string | null;
created_at?: string | null;
started_at?: string | null;
expired_at?: string | null;
is_featured?: boolean | null;
is_hidden?: boolean | null;
is_active?: boolean | null;
status?: string | null;
type?: string | null;
categories?: RawHomeCategory[] | null;
thumbnail?: RawHomeThumbnail | null;
};
type HomeEnvelope<T> = {
responseData?: T;
};
type HomePagedResult<T> = {
rows?: T[];
};
export type HomePostCategory = {
id: string;
name: string;
url: string;
type: string;
};
export type HomePostItem = {
id: string;
title: string;
externalLink: string;
summary: string;
createdAt: string;
publishedAt: string;
startedAt: string;
expiredAt: string;
isFeatured: boolean;
isHidden: boolean;
isActive: boolean;
status: string;
type: string;
categories: HomePostCategory[];
thumbnail: {
url: string;
alt: string;
} | null;
};
const HOME_POSTS_QUERY_KEY = ["home-page-posts"] as const;
const HOME_CATEGORY_NAMES = {
tinVcci: "Tin VCCI",
tinKinhTe: "Tin Kinh tế",
chuyenDe: "Chuyên đề",
suKien: "Sự kiện",
coHoiKinhDoanh: "Cơ hội kinh doanh",
chinhSachPhapLuat: "Thông tin Chính sách và Pháp luật",
ketNoiHoiVien: "Kết nối hội viên",
} as const;
const normalizeText = (value?: string | null) => value?.trim().toLowerCase() ?? "";
const normalizeLink = (value?: string | null, fallback = "/") => {
const trimmed = value?.trim();
if (!trimmed) return fallback;
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed;
if (trimmed.startsWith("/")) return trimmed;
return `/${trimmed}`;
};
const resolveAssetUrl = (value?: string | null) => {
const trimmed = value?.trim();
if (!trimmed) return "/thumbnail.png";
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed;
if (trimmed.startsWith("/")) {
return `${Links.imageEndpoint.replace(/\/+$/, "")}${trimmed}`;
}
return `${Links.imageEndpoint}${trimmed.replace(/^\/+/, "")}`;
};
const sortByPublishedDesc = (items: HomePostItem[]) =>
[...items].sort((left, right) => {
const leftTime = new Date(left.publishedAt || left.createdAt).getTime();
const rightTime = new Date(right.publishedAt || right.createdAt).getTime();
return rightTime - leftTime;
});
const sortByEventStartAsc = (items: HomePostItem[]) =>
[...items].sort((left, right) => {
const leftTime = new Date(left.startedAt || left.publishedAt || left.createdAt).getTime();
const rightTime = new Date(right.startedAt || right.publishedAt || right.createdAt).getTime();
return leftTime - rightTime;
});
const uniquePosts = (items: HomePostItem[]) => {
const seen = new Set<string>();
return items.filter((item) => {
if (!item.id || seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
};
const matchesCategoryName = (item: HomePostItem, categoryName: string) => {
const normalizedTarget = normalizeText(categoryName);
return item.categories.some(
(category) => normalizeText(category.name) === normalizedTarget,
);
};
const isVisibleNewsPost = (item: HomePostItem) => {
if (item.type && item.type !== "news") return false;
if (item.isHidden) return false;
if (!item.isActive) return false;
if (item.status && item.status !== "published") return false;
return true;
};
async function fetchHomePosts() {
const query = new URLSearchParams({
page: "1",
pageSize: "200",
sortField: "created_at",
sortOrder: "desc",
});
const response = await useCustomClient<HomeEnvelope<HomePagedResult<RawHomePost>>>(
`/post?${query.toString()}`,
);
const rows = response.responseData?.rows ?? [];
return rows
.map<HomePostItem>((item) => {
const categories = (item.categories ?? [])
.filter((category) => category?.id && category?.name)
.map((category) => ({
id: String(category.id),
name: String(category.name),
url: normalizeLink(category.url, "#"),
type: String(category.type ?? ""),
}));
const thumbnailPath = item.thumbnail?.path ?? item.thumbnail?.original ?? null;
const title = String(item.title ?? "").trim();
const externalLink = normalizeLink(
item.external_link || (title ? `/${title}` : undefined),
"#",
);
return {
id: String(item.id ?? ""),
title,
externalLink,
summary: String(item.summary ?? item.content ?? ""),
createdAt: String(item.created_at ?? ""),
publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""),
startedAt: String(item.started_at ?? ""),
expiredAt: String(item.expired_at ?? ""),
isFeatured: Boolean(item.is_featured),
isHidden: Boolean(item.is_hidden),
isActive: item.is_active !== false,
status: String(item.status ?? ""),
type: String(item.type ?? ""),
categories,
thumbnail: thumbnailPath
? {
url: resolveAssetUrl(thumbnailPath),
alt: title,
}
: null,
};
})
.filter((item) => item.id && item.title);
}
export function useHomePosts() {
const query = useQuery({
queryKey: HOME_POSTS_QUERY_KEY,
queryFn: fetchHomePosts,
staleTime: 5 * 60 * 1000,
});
const allPosts = React.useMemo(
() => uniquePosts(query.data ?? []),
[query.data],
);
const posts = React.useMemo(
() => allPosts.filter(isVisibleNewsPost),
[allPosts],
);
const categoryLinks = React.useMemo(() => {
const entries = posts.flatMap((item) => item.categories);
const map = new Map<string, string>();
entries.forEach((category) => {
const key = normalizeText(category.name);
if (!key || map.has(key) || !category.url || category.url === "#") return;
map.set(key, category.url);
});
return map;
}, [posts]);
const tinVcciPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => matchesCategoryName(item, HOME_CATEGORY_NAMES.tinVcci)),
),
[posts],
);
const tinKinhTePosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => matchesCategoryName(item, HOME_CATEGORY_NAMES.tinKinhTe)),
),
[posts],
);
const chuyenDePosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => matchesCategoryName(item, HOME_CATEGORY_NAMES.chuyenDe)),
),
[posts],
);
const featuredPosts = React.useMemo(
() =>
sortByPublishedDesc(
allPosts.filter((item) => item.isFeatured && !item.isHidden),
),
[allPosts],
);
const eventPosts = React.useMemo(
() =>
sortByEventStartAsc(
posts.filter(
(item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.suKien) &&
Boolean(item.startedAt),
),
),
[posts],
);
const businessPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.coHoiKinhDoanh),
),
),
[posts],
);
const policyPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.chinhSachPhapLuat),
),
),
[posts],
);
const memberConnectionPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.ketNoiHoiVien),
),
),
[posts],
);
const allNewsPosts = React.useMemo(
() => uniquePosts([...tinVcciPosts, ...tinKinhTePosts, ...chuyenDePosts]),
[chuyenDePosts, tinKinhTePosts, tinVcciPosts],
);
return {
...query,
allPosts,
posts,
featuredPosts,
eventPosts,
businessPosts,
policyPosts,
memberConnectionPosts,
newsTabs: {
all: allNewsPosts,
tinVcci: tinVcciPosts,
tinKinhTe: tinKinhTePosts,
chuyenDe: chuyenDePosts,
},
categoryLinks,
categoryNames: HOME_CATEGORY_NAMES,
};
}
"use client";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { notFound, useParams, useRouter } from "next/navigation";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config";
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config";
// templates
import InformationPage from "./templates/InformationPage";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui";
import ArticlePage from "./templates/ArticlePage";
import ArticleDetailPage from "./templates/ArticleDetailPage";
import EventPage from "./templates/EventPage";
import EventDetailPage from "./templates/EventDetailPage";
import { Spinner } from "@/components/ui";
import { GetNewsResponseType } from "@/api/types/news";
import { useGetNews } from "@/api/endpoints/news";
import InformationPage from "./templates/InformationPage";
import {
fetchDynamicCategories,
fetchDynamicPostByExternalLink,
fetchDynamicSinglePagePost,
findDynamicCategoryByPath,
findFirstChildCategory,
findMenuCategoryForPost,
} from "./templates/data";
export default function DynamicPage() {
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/");
const routePath = `/${path}`;
const router = useRouter();
// query
const { data: news } = useGetNews<GetNewsResponseType>(
{ filters: `external_link==/${path}` }
const categoryQuery = useQuery({
queryKey: ["dynamic-categories"],
queryFn: fetchDynamicCategories,
staleTime: 5 * 60 * 1000,
});
const detailQuery = useQuery({
queryKey: ["dynamic-post-detail", routePath],
queryFn: () => fetchDynamicPostByExternalLink(routePath),
enabled: Boolean(routePath),
staleTime: 60 * 1000,
});
const matchedCategory = useMemo(
() => findDynamicCategoryByPath(categoryQuery.data ?? [], routePath),
[categoryQuery.data, routePath],
);
const resolvedCategory = useMemo(
() => matchedCategory ?? findMenuCategoryForPost(detailQuery.data ?? null, categoryQuery.data ?? []),
[matchedCategory, detailQuery.data, categoryQuery.data],
);
const { data: category, isLoading, isError } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
static_link: `/${path}`,
const singlePageQuery = useQuery({
queryKey: ["dynamic-single-page-post", resolvedCategory?.id],
queryFn: () => fetchDynamicSinglePagePost(resolvedCategory!.id),
enabled: resolvedCategory?.type === "page",
staleTime: 60 * 1000,
});
// redirect to first child if has children
const children = category?.responseData?.children || [];
useEffect(() => {
if (!category) return;
if (slug.length === 1 && children.length > 0) {
const firstChild = children[0];
if (firstChild?.static_link) {
router.push(firstChild.static_link);
}
if (!matchedCategory || matchedCategory.type !== "category") return;
const firstChild = findFirstChildCategory(matchedCategory, categoryQuery.data ?? []);
if (slug.length === 1 && firstChild?.url) {
router.replace(firstChild.url);
}
}, [slug, category, children, router]);
}, [matchedCategory, categoryQuery.data, router, slug.length]);
//template
if (slug[0] === "hoat-dong" && slug[1] === "su-kien") {
if (slug.length === 2) return <EventPage />;
if (slug.length === 3) return <EventDetailPage />;
}
const isLoading =
categoryQuery.isLoading ||
detailQuery.isLoading ||
(resolvedCategory?.type === "page" && singlePageQuery.isLoading);
if (news?.responseData?.count == 0 && isLoading) {
if (isLoading) {
return (
<div className="flex justify-center items-center w-full h-64">
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
if (news && news?.responseData.rows.length !== 0) {
return <ArticleDetailPage data={news} />;
if (detailQuery.data) {
return (
<ArticleDetailPage
post={detailQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
else if (category?.responseData.is_article == true) {
return <ArticlePage />;
if (resolvedCategory?.type === "page") {
if (!singlePageQuery.data) return notFound();
return (
<InformationPage
post={singlePageQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
else if (category?.responseData.is_article == false) {
return <InformationPage />;
if (resolvedCategory?.type === "news") {
return (
<ArticlePage
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
else if (isError) {
return notFound();
if (resolvedCategory?.type === "category") {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
}
\ No newline at end of file
return notFound();
}
'use client';
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config";
import ListCategory from "@/components/base/list-category";
import { useParams } from "next/dist/client/components/navigation";
import { GetNewsResponseType } from "@/api/types/news";
import EventCalendar from "@/components/base/event-calendar";
import dayjs from "dayjs";
import parse from "html-react-parser";
import EventCalendar from "@/components/base/event-calendar";
import ListCategory from "@/components/base/list-category";
import {
buildDynamicCategoryMenu,
getDynamicPostBodyHtml,
} from "./data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
export default function ArticleDetailPage({ data }: { data: GetNewsResponseType }) {
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
type ArticleDetailPageProps = {
post: DynamicPostItem;
category: DynamicCategoryRouteItem | null;
allCategories: DynamicCategoryRouteItem[];
};
//query
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
code: slug[0],
});
export default function ArticleDetailPage({
post,
category,
allCategories,
}: ArticleDetailPageProps) {
const categoryMenu = category
? buildDynamicCategoryMenu(category, allCategories)
: [];
const children = category?.responseData?.children ?? [];
// template
return (
<div className='container w-full flex justify-center items-center pb-10'>
<div className='flex flex-col gap-5 w-full'>
{children.length !== 0 ? (
<ListCategory categories={children} />
) : (
<br />
)}
<div className="container w-full flex justify-center items-center pb-10">
<div className="flex flex-col gap-5 w-full">
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<main className="lg:col-span-2 bg-white border rounded-md p-8">
<div className='pb-5 text-primary text-2xl leading-normal font-medium'>
{data?.responseData?.rows[0]?.title}
<div className="pb-5 text-primary text-2xl leading-normal font-medium">
{post.title}
</div>
<div className='flex items-center gap-2 text-sm mb-4'>
<span className='text-base text-blue-700'>
{dayjs(data?.responseData?.rows[0]?.created_at).format('DD/MM/YYYY')}
<div className="flex items-center gap-2 text-sm mb-4">
<span className="text-base text-blue-700">
{dayjs(post.release_at ?? post.published_at ?? post.created_at).format("DD/MM/YYYY")}
</span>
</div>
<hr className="my-5" />
<div className='flex-1 text-app-grey text-base overflow-hidden'>
<div className="prose tiptap overflow-hidden">
{parse(data?.responseData?.rows[0]?.description ?? '')}
<div className="flex-1 text-app-grey text-base overflow-hidden">
<div className="prose tiptap max-w-none overflow-hidden">
{parse(getDynamicPostBodyHtml(post))}
</div>
</div>
</main>
......@@ -52,4 +53,4 @@ export default function ArticleDetailPage({ data }: { data: GetNewsResponseType
</div>
</div>
);
}
\ No newline at end of file
}
'use client';
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config";
import ListCategory from "@/components/base/list-category";
import { useParams, useSearchParams, useRouter, usePathname } from "next/navigation";
import { useGetNews } from "@/api/endpoints/news";
import { GetNewsResponseType } from "@/api/types/news";
import CardNews from "@/components/base/card-news";
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import ListFilter from "@/components/base/list-filter";
import EventCalendar from "@/components/base/event-calendar";
import { useState, useEffect } from "react";
import { Spinner } from "@/components/ui";
import ListCategory from "@/components/base/list-category";
import {
buildDynamicCategoryMenu,
buildPostFilters,
fetchDynamicPostList,
stripHtml,
} from "./data";
import type { DynamicCategoryRouteItem } from "./types";
import CardNews from "@/components/base/card-news";
export default function ArticlePage() {
// get url
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/");
type ArticlePageProps = {
category: DynamicCategoryRouteItem;
allCategories: DynamicCategoryRouteItem[];
};
export default function ArticlePage({ category, allCategories }: ArticlePageProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParamsString = searchParams.toString();
// states
const initialPage = Number(searchParams.get("page") ?? "1");
const [submitSearch, setSubmitSearch] = useState("");
const [page, setPage] = useState(initialPage);
const pageSize = 5;
const pageSize = 6;
const keyword = submitSearch.trim();
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
const params = new URLSearchParams(searchParamsString);
if (page > 1) {
params.set("page", String(page));
} else {
params.delete("page");
}
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [page]);
const nextUrl = qs ? `${pathname}?${qs}` : pathname;
const currentUrl = searchParamsString ? `${pathname}?${searchParamsString}` : pathname;
// query
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
code: slug[0],
});
if (nextUrl !== currentUrl) {
router.replace(nextUrl, { scroll: false });
}
}, [page, pathname, router, searchParamsString]);
const { data: articles, isLoading: articlesLoading } = useGetNews<GetNewsResponseType>({
filters: `page_config.static_link==/${path}` + (submitSearch ? `,title@=${submitSearch}` : ""),
pageSize: String(pageSize),
currentPage: String(page),
useEffect(() => {
setPage(1);
}, [submitSearch, category.id]);
const postsQuery = useQuery({
queryKey: ["dynamic-posts", category.id, page, pageSize, keyword],
queryFn: () =>
fetchDynamicPostList({
page,
pageSize,
filters: buildPostFilters([
`category.id==${category.id}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
keyword ? `title@=${keyword}` : null,
]),
}),
staleTime: 60 * 1000,
});
const children = category?.responseData?.children ?? [];
//template
const categoryMenu = useMemo(
() => buildDynamicCategoryMenu(category, allCategories),
[category, allCategories],
);
const totalPages = postsQuery.data?.totalPages ?? 1;
const currentPage = Math.min(page, totalPages);
const paginatedPosts = postsQuery.data?.rows ?? [];
return (
<div className="min-h-screen container mx-auto">
{articlesLoading ? (
{postsQuery.isLoading ? (
<div className="flex justify-center items-center w-full h-64">
<Spinner />
</div>
) : (
<div className="w-full flex flex-col gap-5">
{children.length !== 0 ? (
<ListCategory categories={children} />
) : (
<br />
)}
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<main className="lg:col-span-2 bg-background">
<div className="pb-5 overflow-hidden">
{articles?.responseData?.rows.map((item) => (
<CardNews
key={item.id}
news={item}
link={`${item.external_link}`}
/>
))}
{paginatedPosts.length ? (
paginatedPosts.map((item) => {
const fallbackDescription = item.content_structure?.post_content
?.map((section) => section.content)
.join(" ");
return (
<CardNews
key={item.id}
news={{
id: item.id,
title: item.title,
thumbnail:
item.thumbnail?.path ??
item.thumbnail?.original ??
item.thumbnail?.url ??
"",
external_link: item.external_link,
description:
item.summary ||
stripHtml(item.content) ||
stripHtml(fallbackDescription),
release_at:
item.release_at ?? item.published_at ?? item.created_at ?? "",
is_active: item.is_active,
created_at: item.created_at ?? "",
created_by: null,
updated_at: item.created_at ?? "",
updated_by: null,
mode: "NOW",
category: category.name,
page_config: {
id: category.id,
name: category.name,
static_link: category.url,
static_link_en: category.url,
code: category.slug,
},
}}
link={item.external_link}
/>
);
})
) : (
<div className="rounded-lg border bg-white px-6 py-12 text-center text-gray-600">
{"Ch\u01b0a c\u00f3 b\u00e0i vi\u1ebft ph\u00f9 h\u1ee3p trong danh m\u1ee5c n\u00e0y."}
</div>
)}
<div className="w-full flex justify-center mt-4">
<Pagination
pageCount={Number(articles?.responseData?.totalPages ?? 1)}
page={Number(articles?.responseData?.currentPage ?? page)}
pageCount={totalPages}
page={currentPage}
onChangePage={setPage}
onGoToPreviousPage={() => setPage(Math.max(1, page - 1))}
onGoToNextPage={() =>
setPage(Math.min(Number(articles?.responseData?.totalPages ?? 1), page + 1))
}
onGoToPreviousPage={() => setPage(Math.max(1, currentPage - 1))}
onGoToNextPage={() => setPage(Math.min(totalPages, currentPage + 1))}
/>
</div>
</div>
</main>
<aside className="space-y-6">
<ListFilter onSearch={setSubmitSearch} />
<ListFilter onSearch={setSubmitSearch} onReset={() => setSubmitSearch("")} />
<EventCalendar />
<div className="bg-white border rounded-md overflow-hidden">
<div className="w-full relative bg-gray-100">
<img src="/banner.webp" alt="Quảng cáo" className="object-cover" />
<img src="/banner.webp" alt={"Qu\u1ea3ng c\u00e1o"} className="object-cover" />
</div>
</div>
</aside>
......@@ -103,4 +170,4 @@ export default function ArticlePage() {
)}
</div>
);
}
\ No newline at end of file
}
......@@ -23,6 +23,7 @@ export default function EventPage() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParamsString = searchParams.toString();
// states
const initialPage = Number(searchParams.get("page") ?? "1");
......@@ -31,15 +32,20 @@ export default function EventPage() {
const pageSize = 5;
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
const params = new URLSearchParams(searchParamsString);
if (page > 1) {
params.set("page", String(page));
} else {
params.delete("page");
}
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [page]);
const nextUrl = qs ? `${pathname}?${qs}` : pathname;
const currentUrl = searchParamsString ? `${pathname}?${searchParamsString}` : pathname;
if (nextUrl !== currentUrl) {
router.replace(nextUrl, { scroll: false });
}
}, [page, pathname, router, searchParamsString]);
// query
const { data: categoriesPage } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
......@@ -96,4 +102,4 @@ export default function EventPage() {
</div>
</>
);
}
\ No newline at end of file
}
'use client';
import { GetNewsPageConfigResponseType } from "@/api/types/news-page-config";
import { useGetNewsPageConfigGetHierarchical } from "@/api/endpoints/news-page-config";
import ListCategory from "@/components/base/list-category";
import { useParams } from "next/dist/client/components/navigation";
import { Spinner } from "@/components/ui/spinner";
import { GetNewsResponseType } from "@/api/types/news";
import { useGetNews } from "@/api/endpoints/news";
import parse from "html-react-parser";
import ListCategory from "@/components/base/list-category";
import {
buildDynamicCategoryMenu,
getDynamicPostBodyHtml,
} from "./data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
export default function InformationPage() {
// get url
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/");
// query
const { data: category } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>({
static_link: `/${slug[0]}`,
});
type InformationPageProps = {
post: DynamicPostItem;
category: DynamicCategoryRouteItem;
allCategories: DynamicCategoryRouteItem[];
};
const { data: information, isLoading: informationLoading } = useGetNews<GetNewsResponseType>({
filters: `page_config.static_link==/${path}`,
});
export default function InformationPage({
post,
category,
allCategories,
}: InformationPageProps) {
const categoryMenu = buildDynamicCategoryMenu(category, allCategories);
const children = category?.responseData?.children ?? [];
//template
return (
<div className='container w-full flex justify-center items-center pb-10'>
{informationLoading ? (
<div className="flex justify-center items-center w-full h-64">
<Spinner />
</div>
) : (
<div className='flex flex-col gap-5 w-full'>
{children.length !== 0 ? (
<ListCategory categories={children} />
) : (
<br />
)}
<main className=" bg-white border rounded-md py-10 px-5 md:px-20 lg:px-20">
<div className='text-primary text-2xl leading-normal font-bold'>
{information?.responseData?.rows[0]?.title}
</div>
{/* <div className='flex items-center gap-2 text-sm mb-4'>
<span className='text-base text-blue-700'>
{dayjs(information?.responseData?.rows[0].created_at).format('DD/MM/YYYY')}
</span>
</div> */}
<hr className="my-5" />
<div className='flex-1 text-app-grey text-base overflow-hidden'>
<div className="prose tiptap overflow-hidden">
{parse(information?.responseData?.rows[0]?.description ?? '')}
</div>
<div className="container w-full flex justify-center items-center pb-10">
<div className="flex flex-col gap-5 w-full">
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />}
<main className="bg-white border rounded-md py-10 px-5 md:px-20 lg:px-20">
<div className="text-primary text-2xl leading-normal font-bold">
{post.title}
</div>
<hr className="my-5" />
<div className="flex-1 text-app-grey text-base overflow-hidden">
<div className="prose tiptap max-w-none overflow-hidden">
{parse(getDynamicPostBodyHtml(post))}
</div>
</main>
</div>
)}
</div>
</main>
</div>
</div>
);
}
\ No newline at end of file
}
import type { Category } from "@/api/models/category";
import { useCustomClient } from "@/api/mutator/custom-client";
import Links from "@/links";
import { getCategoryFallbackResponse } from "@/mockdata/categories";
import type {
DynamicCategoryMenuItem,
DynamicCategoryRouteItem,
DynamicCategoryType,
DynamicPostContentSection,
DynamicPostItem,
DynamicPostThumbnail,
} from "./types";
type CategoryListResponse = {
responseData?: {
rows?: Category[];
};
};
type RawPostCategory = {
id?: string | null;
name?: string | null;
url?: string | null;
type?: string | null;
};
type RawPostThumbnail = {
path?: string | null;
original?: string | null;
url?: string | null;
};
type RawPostItem = {
id?: string | null;
title?: string | null;
slug?: string | null;
external_link?: string | null;
content?: string | null;
summary?: string | null;
release_at?: string | null;
published_at?: string | null;
created_at?: string | null;
started_at?: string | null;
ended_at?: string | null;
expired_at?: string | null;
registration_deadline?: string | null;
is_featured?: boolean | null;
is_hidden?: boolean | null;
is_active?: boolean | null;
status?: string | null;
type?: string | null;
thumbnail?: RawPostThumbnail | null;
categories?: RawPostCategory[] | null;
content_structure?: {
post_content?: Array<{
id?: string | null;
type?: string | null;
content?: string | null;
position?: number | null;
}> | null;
} | null;
};
type PostListResponse = {
responseData?: {
count?: number;
page?: number;
pageSize?: number;
rows?: RawPostItem[];
};
};
export type DynamicPostListResult = {
count: number;
page: number;
pageSize: number;
totalPages: number;
rows: DynamicPostItem[];
};
const normalizePath = (value?: string | null) => {
const trimmed = value?.trim() ?? "";
if (!trimmed || trimmed === "/") return "/";
return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
};
const normalizeCategoryType = (value?: string | null): DynamicCategoryType | null => {
if (value === "category" || value === "page" || value === "news") return value;
return null;
};
const sortCategories = (items: DynamicCategoryRouteItem[]) =>
[...items].sort((left, right) => {
const leftOrder = left.sort_order ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.sort_order ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return left.name.localeCompare(right.name, "vi");
});
const mapPostContentSections = (item: RawPostItem): DynamicPostContentSection[] => {
const sections = Array.isArray(item.content_structure?.post_content)
? item.content_structure.post_content
: [];
return sections.map((section, index) => ({
id: String(section?.id ?? `section-${index + 1}`),
type: String(section?.type ?? "text"),
content: String(section?.content ?? ""),
position:
typeof section?.position === "number"
? section.position
: index + 1,
}));
};
const mapPost = (item: RawPostItem): DynamicPostItem => ({
id: String(item.id ?? ""),
title: String(item.title ?? "").trim(),
slug: String(item.slug ?? "").trim(),
external_link: normalizePath(item.external_link),
content: String(item.content ?? ""),
summary: String(item.summary ?? ""),
release_at: item.release_at ?? null,
published_at: item.published_at ?? null,
created_at: item.created_at ?? null,
started_at: item.started_at ?? null,
ended_at: item.ended_at ?? null,
expired_at: item.expired_at ?? null,
registration_deadline: item.registration_deadline ?? null,
is_featured: Boolean(item.is_featured),
is_hidden: Boolean(item.is_hidden),
is_active: item.is_active !== false,
status: String(item.status ?? ""),
type: String(item.type ?? ""),
thumbnail: (item.thumbnail ?? null) as DynamicPostThumbnail,
categories: (item.categories ?? [])
.filter((category) => category?.id && category?.name)
.map((category) => ({
id: String(category.id),
name: String(category.name),
url: normalizePath(category.url),
type: String(category.type ?? ""),
})),
content_structure: {
post_content: mapPostContentSections(item),
},
});
const buildPostFilters = (filters: Array<string | null | undefined>) =>
filters
.map((item) => item?.trim())
.filter(Boolean)
.join(",");
export async function fetchDynamicCategories(): Promise<DynamicCategoryRouteItem[]> {
const response = await useCustomClient<CategoryListResponse>(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC",
).catch(() => getCategoryFallbackResponse());
const rows = response.responseData?.rows ?? [];
return sortCategories(
rows
.map((item) => {
const type = normalizeCategoryType(item.type);
if (!item.id || !item.name || !type) return null;
return {
id: item.id,
name: item.name,
slug: item.slug ?? "",
url: normalizePath(item.url),
type,
parent_id: item.parent_id ?? null,
sort_order: item.sort_order ?? null,
} satisfies DynamicCategoryRouteItem;
})
.filter((item): item is DynamicCategoryRouteItem => Boolean(item)),
);
}
export async function fetchDynamicPostList(params: {
filters?: string;
page?: number;
pageSize?: number;
sortField?: string;
sortOrder?: string;
}): Promise<DynamicPostListResult> {
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 5;
const query = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
sortField: params.sortField ?? "release_at",
sortOrder: params.sortOrder ?? "desc",
});
if (params.filters?.trim()) {
query.set("filters", params.filters.trim());
}
const response = await useCustomClient<PostListResponse>(`/post?${query.toString()}`);
const count = Number(response.responseData?.count ?? 0);
return {
count,
page,
pageSize,
totalPages: pageSize > 0 ? Math.max(1, Math.ceil(count / pageSize)) : 1,
rows: (response.responseData?.rows ?? []).map(mapPost).filter((item) => item.id && item.title),
};
}
export async function fetchDynamicPostByExternalLink(path: string) {
const result = await fetchDynamicPostList({
page: 1,
pageSize: 1,
filters: buildPostFilters([
`external_link==${normalizePath(path)}`,
"is_hidden==false",
"is_active==true",
"status==published",
]),
});
return result.rows[0] ?? null;
}
export async function fetchDynamicSinglePagePost(categoryId: string) {
const result = await fetchDynamicPostList({
page: 1,
pageSize: 1,
filters: buildPostFilters([
`category.id==${categoryId}`,
"is_hidden==false",
"is_active==true",
"type==page",
]),
});
return result.rows[0] ?? null;
}
export function findDynamicCategoryByPath(
categories: DynamicCategoryRouteItem[],
path: string,
) {
const normalizedPath = normalizePath(path);
return categories.find((item) => normalizePath(item.url) === normalizedPath) ?? null;
}
export function findMenuCategoryForPost(
post: DynamicPostItem | null,
categories: DynamicCategoryRouteItem[],
) {
if (!post) return null;
for (const category of post.categories) {
const matched = categories.find((item) => item.id === category.id);
if (matched) return matched;
}
return null;
}
export function buildDynamicCategoryMenu(
activeCategory: DynamicCategoryRouteItem | null,
categories: DynamicCategoryRouteItem[],
): DynamicCategoryMenuItem[] {
if (!activeCategory) return [];
const relatedItems = activeCategory.parent_id
? categories.filter((item) => item.parent_id === activeCategory.parent_id)
: categories.filter((item) => item.parent_id === activeCategory.id);
return sortCategories(relatedItems).map((item) => ({
id: item.id,
name: item.name,
static_link: item.url,
}));
}
export function findFirstChildCategory(
category: DynamicCategoryRouteItem,
categories: DynamicCategoryRouteItem[],
) {
return sortCategories(categories.filter((item) => item.parent_id === category.id))[0] ?? null;
}
export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) {
const value = thumbnail?.path ?? thumbnail?.original ?? thumbnail?.url ?? "";
if (!value) return "/thumbnail.png";
if (value.startsWith("http://") || value.startsWith("https://")) return value;
if (value.startsWith("/")) return `${Links.imageEndpoint.replace(/\/+$/, "")}${value}`;
return `${Links.imageEndpoint}${value.replace(/^\/+/, "")}`;
}
export function stripHtml(value?: string | null) {
if (!value) return "";
return value
.replace(/<img[^>]*>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function getDynamicPostBodyHtml(post: DynamicPostItem | null) {
if (!post) return "";
const primaryContent = post.content?.trim();
if (primaryContent) return primaryContent;
const structuredContent = (post.content_structure?.post_content ?? [])
.sort((left, right) => left.position - right.position)
.map((section) => section.content?.trim() ?? "")
.filter(Boolean)
.join("\n");
return structuredContent || post.summary?.trim() || "";
}
export function matchesDynamicPostCategory(post: DynamicPostItem, categoryId: string) {
return post.categories.some((category) => category.id === categoryId);
}
export function isDynamicPostVisible(post: DynamicPostItem) {
if (post.is_hidden) return false;
if (!post.is_active) return false;
if (post.status && post.status !== "published") return false;
return true;
}
export { buildPostFilters, normalizePath };
export type DynamicCategoryType = "category" | "page" | "news";
export type DynamicCategoryRouteItem = {
id: string;
name: string;
slug: string;
url: string;
type: DynamicCategoryType;
parent_id: string | null;
sort_order: number | null;
};
export type DynamicCategoryMenuItem = {
id: string;
name: string;
static_link: string;
};
export type DynamicPostCategoryItem = {
id: string;
name: string;
url: string;
type: string;
};
export type DynamicPostThumbnail = {
path?: string | null;
original?: string | null;
url?: string | null;
} | null;
export type DynamicPostContentSection = {
id: string;
type: string;
content: string;
position: number;
};
export type DynamicPostItem = {
id: string;
title: string;
slug: string;
external_link: string;
content: string;
summary: string;
release_at: string | null;
published_at: string | null;
created_at: string | null;
started_at: string | null;
ended_at: string | null;
expired_at: string | null;
registration_deadline: string | null;
is_featured: boolean;
is_hidden: boolean;
is_active: boolean;
status: string;
type: string;
thumbnail: DynamicPostThumbnail;
categories: DynamicPostCategoryItem[];
content_structure: {
post_content: DynamicPostContentSection[];
} | null;
};
......@@ -432,7 +432,11 @@ export default function AdminBaseConfigPage() {
saveConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
toast.success(itemDialogMode === "logo" ? "Đã lưu cấu hình logo" : "Đã lưu cấu hình banner");
toast.success(
itemDialogMode === "logo"
? "Đã lưu cấu hình logo"
: "Đã lưu cấu hình banner",
);
};
const handleDeleteItem = () => {
......@@ -575,7 +579,7 @@ export default function AdminBaseConfigPage() {
value="social"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Mạng xã hội
M?ng x? h?i
</TabsTrigger>
</TabsList>
......@@ -700,7 +704,7 @@ export default function AdminBaseConfigPage() {
</div>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-8 text-center text-sm text-gray-500">
Chưa có logo nào. Hãy thiết lập logo cho website.
Chua c? logo n?o. H?y thi?t l?p logo cho website.
</div>
)}
</div>
......@@ -974,7 +978,7 @@ export default function AdminBaseConfigPage() {
</>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-10 text-center text-sm text-gray-500">
Chưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu hình.
Chua c? chi nh?nh n?o. H?y th?m chi nh?nh d? b?t d?u c?u h?nh.
</div>
)}
</div>
......@@ -1018,9 +1022,9 @@ export default function AdminBaseConfigPage() {
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Mạng xã hội</CardTitle>
<CardTitle className="text-2xl text-[#163b73]">M?ng x? h?i</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý link mạng xã hội và thứ tự hiển thị trên website.
Qu?n l? link m?ng x? h?i v? th? t? hi?n th? tr?n website.
</CardDescription>
</div>
......@@ -1030,7 +1034,7 @@ export default function AdminBaseConfigPage() {
className="rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
Lưu mạng xã hội
Luu m?ng x? h?i
</Button>
</div>
</CardHeader>
......
......@@ -2,13 +2,13 @@
import * as React from "react";
import dayjs from "dayjs";
import { Eye, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
......@@ -177,26 +177,12 @@ export default function AdminContactRequestsPage() {
{formatDateTime(item.submittedAt)}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDetailTarget(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "view", label: "Xem chi tiết đơn", onClick: () => setDetailTarget(item) },
{ kind: "delete", label: "Xóa đơn liên hệ", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
......
......@@ -2,13 +2,13 @@
import * as React from "react";
import dayjs from "dayjs";
import { Eye, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
......@@ -136,26 +136,16 @@ export default function AdminMembershipApplicationsPage() {
{formatDateTime(item.submittedAt)}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDetailTarget(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "view", label: "Xem chi tiết đơn", onClick: () => setDetailTarget(item) },
{
kind: "delete",
label: "Xóa đơn đăng ký hội viên",
onClick: () => setDeleteTarget(item),
},
]}
/>
</TableCell>
</TableRow>
))
......
......@@ -2,12 +2,12 @@
import * as React from "react";
import dayjs from "dayjs";
import { Eye, Mail, Trash2 } from "lucide-react";
import { Mail } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
......@@ -116,28 +116,14 @@ export default function AdminNewsletterEmailsPage() {
<TableCell className="py-3 text-center text-sm text-gray-700">
{formatDateTime(item.submittedAt)}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDetailTarget(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
<TableCell className="py-3 text-center">
<AdminRowActions
actions={[
{ kind: "view", label: "Xem chi tiết email", onClick: () => setDetailTarget(item) },
{ kind: "delete", label: "Xóa email đăng ký", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
)}
......
......@@ -159,7 +159,7 @@ export default function AdminDashboardPage() {
() => [
{
title: "Cấu hình chung",
description: "Logo, banner, chi nhánh liên hệ và mạng xã hội",
description: "Logo, banner, chi nh?nh li?n h? v? m?ng x? h?i",
href: "/admin/base-config",
icon: Globe,
},
......@@ -313,7 +313,7 @@ export default function AdminDashboardPage() {
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{activeBanners.length}</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Mạng xã hội hiển thị</div>
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">M?ng x? h?i hi?n th?</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{visibleSocials.length}</div>
</div>
</div>
......
"use client";
"use client";
import React from "react";
import dayjs from "dayjs";
......@@ -7,27 +7,18 @@ import { useParams, useRouter } from "next/navigation";
import { toast } from "sonner";
import {
ArrowLeft,
Edit,
EyeOff,
FileText,
MoreHorizontal,
Plus,
Star,
Trash2,
} from "lucide-react";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
......@@ -358,37 +349,22 @@ export default function HeaderCategoryPostsPage() {
)}
</TableCell>
<TableCell className="text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
asChild
className="text-gray-700 focus:text-[#063e8e]"
>
<Link href={`/admin/header-config/${categoryId}/posts/${item.id}`}>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
<TableCell className="text-center">
<AdminRowActions
actions={[
{
kind: "edit",
label: "Chỉnh sửa bài viết",
onClick: () => router.push(`/admin/header-config/${categoryId}/posts/${item.id}`),
},
{
kind: "delete",
label: "Xóa bài viết",
onClick: () => setDeleteTarget(item),
},
]}
/>
</TableCell>
</TableRow>
))
)}
......
......@@ -5,24 +5,14 @@ import Link from "next/link";
import {
ChevronDown,
ChevronRight,
Edit,
ExternalLink,
FileText,
FolderTree,
MoreHorizontal,
Plus,
Trash,
} from "lucide-react";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
......@@ -42,6 +32,8 @@ export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
parentId: string | null;
};
const PROTECTED_HOME_CATEGORY_ID = "root-home";
interface HeaderCategoryTableProps {
rows: HeaderCategoryFlatRow[];
expanded: Record<string, boolean>;
......@@ -160,8 +152,11 @@ export function HeaderCategoryTable({
rows.map((item, index) => {
const hasChildren = item.children.length > 0;
const isExpanded = expanded[item.id] ?? true;
const canCreateChild = !item.parent_id && item.type === "category";
const canManagePosts = item.type === "page" || item.type === "news";
const isProtectedHomeCategory = item.id === PROTECTED_HOME_CATEGORY_ID;
const canCreateChild =
!isProtectedHomeCategory && !item.parent_id && item.type === "category";
const canManagePosts =
!isProtectedHomeCategory && (item.type === "page" || item.type === "news");
return (
<TableRow
......@@ -221,56 +216,46 @@ export function HeaderCategoryTable({
</TableCell>
<TableCell className="w-[120px] text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onEdit(item)}
>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</DropdownMenuItem>
{canManagePosts ? (
<DropdownMenuItem
asChild
className="text-gray-700 focus:text-[#063e8e]"
>
<Link href={`/admin/header-config/${item.id}/posts`}>
<FileText className="mr-2 h-4 w-4" />
Quản lý bài viết
</Link>
</DropdownMenuItem>
) : null}
{canCreateChild ? (
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onCreateChild(item)}
>
<Plus className="mr-2 h-4 w-4" />
Thêm danh mục con
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onDelete(item)}
>
<Trash className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AdminRowActions
actions={[
...(!isProtectedHomeCategory
? [
{
kind: "edit" as const,
label: "Chỉnh sửa danh mục",
onClick: () => onEdit(item),
},
]
: []),
...(canManagePosts
? [
{
kind: "manage" as const,
label: "Quản lý bài viết",
href: `/admin/header-config/${item.id}/posts`,
},
]
: []),
...(canCreateChild
? [
{
kind: "create-child" as const,
label: "Thêm danh mục con",
onClick: () => onCreateChild(item),
},
]
: []),
...(!isProtectedHomeCategory
? [
{
kind: "delete" as const,
label: "Xóa danh mục",
onClick: () => onDelete(item),
},
]
: []),
]}
/>
</TableCell>
</TableRow>
);
......
......@@ -34,6 +34,12 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
description: "",
};
const PROTECTED_HOME_CATEGORY_ID = "root-home";
function isProtectedHomeCategory(itemId?: string | null) {
return itemId === PROTECTED_HOME_CATEGORY_ID;
}
function toFormValues(item?: CmsHeaderCategoryItem | null): HeaderCategoryFormValues {
if (!item) return EMPTY_HEADER_CATEGORY_FORM;
......@@ -199,6 +205,8 @@ export default function HeaderConfigPage() {
};
const openEdit = (item: HeaderCategoryTreeItem) => {
if (isProtectedHomeCategory(item.id)) return;
const fullItem = itemMap.get(item.id) ?? null;
setFormMode("edit");
setFormValues(toFormValues(fullItem));
......@@ -223,6 +231,10 @@ export default function HeaderConfigPage() {
const handleSubmit = async () => {
if (isSubmitting) return;
if (isProtectedHomeCategory(formValues.id)) {
return;
}
if (!formValues.name.trim()) {
toast.error("Tên danh mục là bắt buộc");
return;
......@@ -293,6 +305,11 @@ export default function HeaderConfigPage() {
const handleDelete = async () => {
if (!deleteTarget || isSubmitting) return;
if (isProtectedHomeCategory(deleteTarget.id)) {
setDeleteTarget(null);
return;
}
setIsSubmitting(true);
try {
......
......@@ -14,33 +14,17 @@ import {
ShieldCheck,
} from "lucide-react";
import { toast } from "sonner";
import { postAuthLogin } from "@/api/endpoints/auth";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import logo from "@/assets/VCCI-HCM-logo-VN-2025.png";
import { loginAdmin } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore";
type AuthMode = "login" | "forgot" | "reset";
type ResetStep = "request" | "verify" | "password" | "done";
type LoginApiSuccess = {
responseData?: LoginPayload;
data?: {
responseData?: LoginPayload;
};
};
type LoginPayload = {
access_token?: string;
refresh_token?: string;
expires_in?: number;
user?: {
email?: string;
};
};
type ApiEnvelope<T = unknown> = {
responseData?: T;
data?: {
......@@ -141,8 +125,8 @@ function AuthShell({
mode === "login"
? "Truy cập khu vực quản trị nội dung VCCI News."
: mode === "forgot"
? "Xác thực email quản trị để nhận mã OTP."
: "Nhập mã OTP và tạo mật khẩu mới cho tài khoản.";
? "X?c th?c email qu?n tr? d? nh?n m? OTP."
: "Nh?p m? OTP v? t?o m?t kh?u m?i cho t?i kho?n.";
return (
<div className="min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700">
......@@ -290,7 +274,6 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const rememberState = useAuthStore((state) => state.appUserRemember);
const setAppToken = useAuthStore((state) => state.setAppToken);
const setAppUserRemember = useAuthStore((state) => state.setAppUserRemember);
const [mode, setMode] = useState<AuthMode>("login");
......@@ -329,18 +312,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setLoginLoading(true);
try {
const response = await postAuthLogin({
email: email.trim(),
password,
});
const payload = getResponseData<LoginPayload>(response as unknown as LoginApiSuccess);
if (!payload?.access_token || !payload.expires_in) {
throw new Error("Thiếu dữ liệu token từ API đăng nhập.");
}
setAppToken(payload.access_token, payload.expires_in, payload.refresh_token);
await loginAdmin(email.trim(), password);
setAppUserRemember(remember ? email.trim() : "", remember ? password : "", remember);
toast.success("Đăng nhập quản trị thành công");
......@@ -365,9 +337,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetStep("verify");
setMode("reset");
setResetMessage("Mã OTP đã được gửi đến email quản trị.");
setResetMessage("M? OTP d? du?c g?i d?n email qu?n tr?.");
} catch (error) {
setResetError(getAuthErrorMessage(error, "Không thể gửi mã OTP. Vui lòng thử lại."));
setResetError(getAuthErrorMessage(error, "Kh?ng th? g?i m? OTP. Vui l?ng th? l?i."));
} finally {
setResetLoading(false);
}
......@@ -390,14 +362,14 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const payload = getResponseData<VerifyOtpPayload>(response);
if (!payload?.reset_token) {
throw new Error("Không nhận được mã đặt lại mật khẩu từ API.");
throw new Error("Kh?ng nh?n du?c m? d?t l?i m?t kh?u t? API.");
}
setResetToken(payload.reset_token);
setResetStep("password");
setResetMessage("OTP hợp lệ. Bạn có thể tạo mật khẩu mới.");
} catch (error) {
setResetError(getAuthErrorMessage(error, "OTP không hợp lệ hoặc đã hết hạn."));
setResetError(getAuthErrorMessage(error, "OTP kh?ng h?p l? ho?c d? h?t h?n."));
} finally {
setResetLoading(false);
}
......@@ -571,7 +543,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
Đang gửi OTP...
</>
) : (
"Gửi mã OTP"
"G?i m? OTP"
)}
</Button>
......@@ -621,7 +593,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<form className="space-y-5" onSubmit={handleVerifyOtp}>
<div className="space-y-2">
<Label htmlFor="otp" className="text-gray-700">
Mã OTP
M? OTP
</Label>
<Input
id="otp"
......@@ -650,7 +622,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
onClick={switchToForgot}
className="h-10 w-full rounded-xl text-gray-700 hover:bg-[#edf4ff] hover:text-[#063e8e]"
>
Gửi lại mã OTP
G?i l?i m? OTP
</Button>
</form>
) : null}
......@@ -700,7 +672,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<div className="space-y-5">
<div className="rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-[#063e8e]" />
<div className="mt-3 text-base font-semibold text-gray-900">Mật khẩu đã được cập nhật</div>
<div className="mt-3 text-base font-semibold text-gray-900">M?t kh?u d? du?c c?p nh?t</div>
<p className="mt-2 text-sm leading-6 text-gray-700">
Quay lại màn đăng nhập để vào khu vực quản trị bằng mật khẩu mới.
</p>
......
......@@ -429,7 +429,7 @@ export default function AdminMediaPage() {
</div>
<h2 className="mt-5 text-lg font-semibold text-slate-800">Chưa có ảnh phù hợp</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
Hãy tải ảnh mới hoặc thử lại với từ khóa khác để tìm đúng hình ảnh bạn cần.
H?y t?i ?nh m?i ho?c th? l?i v?i t? kh?a kh?c d? t?m d?ng h?nh ?nh b?n c?n.
</p>
</div>
) : (
......
"use client";
import * as React from "react";
import { Edit, Plus, Save, Trash2, X } from "lucide-react";
import { Plus, Save, X } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
......@@ -206,26 +207,12 @@ export default function AdminMemberFieldsPage() {
{item.name}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => openEdit(item)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "edit", label: "Chỉnh sửa lĩnh vực", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa lĩnh vực", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
......
"use client";
import * as React from "react";
import { Edit, MoreHorizontal, Plus, Trash2, Users } from "lucide-react";
import { Plus, Users } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
......@@ -258,34 +252,20 @@ export default function AdminMembersPage() {
<span className="line-clamp-2">{item.address || "—"}</span>
</TableCell>
<TableCell className="px-4 py-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-[#063e8e]/15">
<DropdownMenuItem
className="cursor-pointer text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"
onClick={() => router.push(`/admin/members/${item.id}`)}
>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:bg-red-50 focus:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AdminRowActions
actions={[
{
kind: "edit",
label: "Chỉnh sửa hội viên",
onClick: () => router.push(`/admin/members/${item.id}`),
},
{
kind: "delete",
label: "Xóa hội viên",
onClick: () => setDeleteTarget(item),
},
]}
/>
</TableCell>
</TableRow>
))
......
"use client";
import * as React from "react";
import { Edit, Plus, Save, Trash2, X } from "lucide-react";
import { Plus, Save, X } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
......@@ -206,26 +207,12 @@ export default function AdminMemberRegionsPage() {
{item.name}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => openEdit(item)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "edit", label: "Chỉnh sửa khu vực", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa khu vực", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
......
This diff is collapsed.
......@@ -2,9 +2,10 @@
import * as React from "react";
import dayjs from "dayjs";
import { Edit2, Hash, Plus, Tag, Trash2 } from "lucide-react";
import { ChevronLeft, ChevronRight, Hash, Plus, Tag } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
......@@ -30,7 +31,7 @@ import {
type CmsTagItem,
createCmsTag,
deleteCmsTag,
fetchCmsTags,
fetchCmsTagsPage,
updateCmsTag,
} from "@/lib/api/cms-admin";
......@@ -68,12 +69,17 @@ export default function AdminTagsPage() {
const [formOpen, setFormOpen] = React.useState(false);
const [formValues, setFormValues] = React.useState<TagFormValues>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = React.useState<CmsTagItem | null>(null);
const [page, setPage] = React.useState(1);
const [pageSize] = React.useState(10);
const [total, setTotal] = React.useState(0);
const load = React.useCallback(async () => {
const nextItems = await fetchCmsTags();
setItems(nextItems);
setIsReady(false);
const result = await fetchCmsTagsPage({ page, pageSize });
setItems(result.items);
setTotal(result.total);
setIsReady(true);
}, []);
}, [page, pageSize]);
React.useEffect(() => {
void load().catch((error) => {
......@@ -93,6 +99,18 @@ export default function AdminTagsPage() {
);
}, [items, search]);
const totalPages = Math.ceil(total / pageSize);
React.useEffect(() => {
setPage((currentPage) => (currentPage === 1 ? currentPage : 1));
}, [search]);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage);
}
};
const openCreate = () => {
setFormValues(EMPTY_FORM);
setFormOpen(true);
......@@ -176,7 +194,7 @@ export default function AdminTagsPage() {
actionDisabled={!isReady}
actionMeta={
<div className="rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]">
Tổng số tags: {items.length}
Tổng số tags: {total}
</div>
}
onSearchChange={setSearch}
......@@ -238,32 +256,77 @@ export default function AdminTagsPage() {
{item.updated_at ? dayjs(item.updated_at).format("DD/MM/YYYY") : "-"}
</TableCell>
<TableCell className="px-4 py-4">
<div className="flex justify-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
onClick={() => openEdit(item)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 border-red-100 bg-white text-red-600 hover:bg-red-50"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "edit", label: "Chỉnh sửa tag", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa tag", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-[#063e8e]/10 px-4 py-3">
<div className="text-sm text-gray-700">
Hiển thị {(page - 1) * pageSize + 1} đến{" "}
{Math.min(page * pageSize, total)} của {total} tag
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, index) => {
let pageNum;
if (totalPages <= 5) {
pageNum = index + 1;
} else if (page <= 3) {
pageNum = index + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + index;
} else {
pageNum = page - 2 + index;
}
return (
<Button
key={pageNum}
variant={page === pageNum ? "default" : "outline"}
size="icon"
className={
page === pageNum
? "h-8 w-8 bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
: "h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
}
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</AdminTableLayout>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
......
"use client";
import * as React from "react";
import { Edit, Plus, Save, Trash2, Video, X } from "lucide-react";
import { Plus, Save, Video, X } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
......@@ -273,26 +274,12 @@ export default function AdminVideosPage() {
</Link>
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => openEdit(item)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<AdminRowActions
actions={[
{ kind: "edit", label: "Chỉnh sửa video", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa video", onClick: () => setDeleteTarget(item) },
]}
/>
</TableCell>
</TableRow>
))
......
......@@ -33,18 +33,24 @@ export function AdminDeleteDialog({
}: AdminDeleteDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogContent className="rounded-3xl border border-[#063e8e]/15 bg-white p-6 shadow-xl">
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
<AlertDialogTitle className="text-xl font-semibold text-gray-900">
{title}
</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-6 text-gray-700">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction className="bg-[#063e8e] hover:bg-[#063e8e]/90" onClick={onConfirm}>
<AlertDialogFooter className="mt-2 gap-2">
<AlertDialogCancel className="mt-0 border-[#063e8e]/15 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900">
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction className="bg-red-600 text-white hover:bg-red-700" onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
}
\ No newline at end of file
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import {
Eye,
FileText,
FolderPlus,
PencilLine,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type AdminRowActionKind = "edit" | "view" | "delete" | "manage" | "create-child";
type AdminRowActionBase = {
label: string;
disabled?: boolean;
};
type AdminRowAction =
| (AdminRowActionBase & {
kind: "edit";
onClick: () => void;
})
| (AdminRowActionBase & {
kind: "view";
onClick: () => void;
})
| (AdminRowActionBase & {
kind: "delete";
onClick: () => void;
})
| (AdminRowActionBase & {
kind: "manage";
href: string;
})
| (AdminRowActionBase & {
kind: "create-child";
onClick: () => void;
});
interface AdminRowActionsProps {
actions: AdminRowAction[];
className?: string;
}
const actionStyles: Record<
AdminRowActionKind,
{
button: string;
icon: ReactNode;
}
> = {
edit: {
button:
"border-[#063e8e]/15 bg-white text-[#063e8e] hover:border-[#063e8e]/25 hover:bg-[#063e8e]/10 hover:text-[#063e8e]",
icon: <PencilLine className="h-4 w-4" />,
},
view: {
button:
"border-emerald-100 bg-white text-emerald-600 hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
icon: <Eye className="h-4 w-4" />,
},
delete: {
button:
"border-red-100 bg-white text-red-600 hover:border-red-200 hover:bg-red-50 hover:text-red-700",
icon: <Trash2 className="h-4 w-4" />,
},
manage: {
button:
"border-[#063e8e]/15 bg-white text-[#063e8e] hover:border-[#063e8e]/25 hover:bg-[#063e8e]/10 hover:text-[#063e8e]",
icon: <FileText className="h-4 w-4" />,
},
"create-child": {
button:
"border-sky-100 bg-white text-sky-600 hover:border-sky-200 hover:bg-sky-50 hover:text-sky-700",
icon: <FolderPlus className="h-4 w-4" />,
},
};
function AdminRowActionButton({ action }: { action: AdminRowAction }) {
const style = actionStyles[action.kind];
const sharedClassName = cn("h-8 w-8 rounded-lg shadow-sm", style.button);
if (action.kind === "manage") {
return (
<Button
asChild
type="button"
variant="outline"
size="icon"
title={action.label}
aria-label={action.label}
disabled={action.disabled}
className={sharedClassName}
>
<Link href={action.href}>{style.icon}</Link>
</Button>
);
}
return (
<Button
type="button"
variant="outline"
size="icon"
title={action.label}
aria-label={action.label}
disabled={action.disabled}
onClick={action.onClick}
className={sharedClassName}
>
{style.icon}
</Button>
);
}
export function AdminRowActions({ actions, className }: AdminRowActionsProps) {
if (actions.length === 0) return null;
return (
<div className={cn("flex items-center justify-center gap-1.5", className)}>
{actions.map((action) => (
<AdminRowActionButton key={`${action.kind}-${action.label}`} action={action} />
))}
</div>
);
}
......@@ -66,4 +66,4 @@ export function AdminTableLayout({
</div>
</div>
);
}
}
\ No newline at end of file
......@@ -143,7 +143,7 @@ export function AdminImagePicker({
<ImagePlus className="mb-3 h-10 w-10 text-[#063e8e]" />
<p className="text-base font-medium text-black">Chưa có hình ảnh phù hợp</p>
<p className="mt-1 text-sm text-gray-700">
Hãy thử từ khóa khác hoặc tải thêm hình ảnh vào thư viện.
H?y th? t? kh?a kh?c ho?c t?i th?m h?nh ?nh v?o thu vi?n.
</p>
</div>
) : (
......@@ -172,7 +172,7 @@ export function AdminImagePicker({
/>
{item.id === selectedId ? (
<div className="absolute right-3 top-3 rounded-full bg-[#063e8e] px-2 py-1 text-xs font-medium text-white">
Đã chọn
?? ch?n
</div>
) : null}
</div>
......
......@@ -521,9 +521,14 @@ export function AdminNewsForm({
title: form.title.trim(),
slug: slugifyAdminNews(form.slug.trim()),
summary: form.summary,
type: form.type,
header_category_id: form.header_category_id,
category_ids:
form.type === "baiviettrang" ? [] : selectedHeaderCategory?.category_ids ?? [],
form.type === "baiviettrang"
? form.header_category_id
? [form.header_category_id]
: []
: selectedHeaderCategory?.category_ids ?? [],
tag_ids: form.type === "baiviettrang" ? [] : selectedTagIds,
is_featured: form.type === "tintuc" ? form.is_featured : false,
thumbnail_id: form.thumbnail && isUuid(form.thumbnail.id) ? form.thumbnail.id : null,
......@@ -812,30 +817,31 @@ export function AdminNewsForm({
</div>
) : null}
{availableSearchTags.length > 0 ? (
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4">
<Label className="mb-3 block text-gray-700">Tag tìm kiếm</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{availableSearchTags.map((item) => (
<label
key={item}
className="flex items-center gap-3 rounded-lg border border-[#063e8e]/10 bg-white px-3 py-2"
>
<Checkbox
checked={form.tagsearch_values.includes(item)}
onCheckedChange={(checked) =>
handleToggleSearchTag(item, checked === true)
}
className="border-[#063e8e]/30 data-[state=checked]:border-[#063e8e] data-[state=checked]:bg-[#063e8e]"
/>
<span className="text-sm text-gray-700">{item}</span>
</label>
))}
</div>
</div>
) : null}
</div>
</div>
{availableSearchTags.length > 0 ? (
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4 xl:col-span-2">
<Label className="mb-3 block text-gray-700">Tag tìm kiếm</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{availableSearchTags.map((item) => (
<label
key={item}
className="flex items-center gap-3 rounded-lg border border-[#063e8e]/10 bg-white px-3 py-2"
>
<Checkbox
checked={form.tagsearch_values.includes(item)}
onCheckedChange={(checked) =>
handleToggleSearchTag(item, checked === true)
}
className="border-[#063e8e]/30 data-[state=checked]:border-[#063e8e] data-[state=checked]:bg-[#063e8e]"
/>
<span className="text-sm text-gray-700">{item}</span>
</label>
))}
</div>
</div>
) : null}
</div>
</FormSection>
......
import dayjs from "dayjs";
import Links from "@links/index";
import { NewsItem } from "@/api/types/news";
import { NewsItem } from '@/api/types/news';
import Links from '@links/index'
import dayjs from 'dayjs';
// Helper: remove <img> tags and extract plain text from HTML
const stripImagesAndHtml = (html?: string) => {
if (!html) return ''
// remove img tags first
const withoutImgs = html.replace(/<img[^>]*>/gi, '')
// use DOMParser on client for robust extraction
if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') {
if (!html) return "";
const withoutImages = html.replace(/<img[^>]*>/gi, "");
if (typeof window !== "undefined" && typeof DOMParser !== "undefined") {
try {
const doc = new DOMParser().parseFromString(withoutImgs, 'text/html')
return doc.body.textContent || ''
const doc = new DOMParser().parseFromString(withoutImages, "text/html");
return doc.body.textContent || "";
} catch {
// fallback to regex
return withoutImages.replace(/<[^>]*>/g, "");
}
}
return withoutImgs.replace(/<[^>]*>/g, '')
}
const CardNews = ({ news, link }: { news: NewsItem, link: string }) => {
return withoutImages.replace(/<[^>]*>/g, "");
};
const resolveThumbnail = (thumbnail?: string) => {
if (!thumbnail) return "/img-error.png";
if (thumbnail.startsWith("http://") || thumbnail.startsWith("https://")) return thumbnail;
if (thumbnail.startsWith("/")) return `${Links.imageEndpoint.replace(/\/+$/, "")}${thumbnail}`;
return `${Links.imageEndpoint}${thumbnail}`;
};
const CardNews = ({ news, link }: { news: NewsItem; link: string }) => {
return (
<a
href={`${link}`}
href={link}
className="flex flex-col hover:no-underline sm:flex-row gap-2 mb-6 bg-white rounded-lg shadow-sm p-4 border items-start min-w-0"
>
<img
src={`${Links.imageEndpoint}${news.thumbnail}`}
src={resolveThumbnail(news.thumbnail)}
alt={news.title}
className="w-full sm:w-56 md:w-64 h-40 md:h-36 object-cover shrink-0"
onError={(e) => {
e.currentTarget.src = "/img-error.png"
e.currentTarget.src = "/img-error.png";
}}
/>
<div className="flex-1 min-w-0 pl-0 sm:pl-4">
<p className="text-primary font-semibold text-base md:text-lg hover:underline line-clamp-2 wrap-break-word">
<p className="text-primary font-semibold text-base md:text-lg hover:underline line-clamp-2 break-words">
{news.title}
</p>
<div className="text-sm my-2 text-[#00AED5]">{dayjs(news.release_at).format('DD/MM/YYYY')}</div>
<div className="text-sm my-2 text-[#00AED5]">
{dayjs(news.release_at).format("DD/MM/YYYY")}
</div>
<div className="text-sm text-[#777] line-clamp-3">
<div className="text-sm prose tiptap">{stripImagesAndHtml(news.description)}</div>
<div className="text-sm prose tiptap">
{stripImagesAndHtml(news.description)}
</div>
</div>
</div>
</a>
)
}
);
};
export default CardNews;
\ No newline at end of file
export default CardNews;
......@@ -7,31 +7,29 @@ import { MenuItem } from "../menu-category";
type Category = {
id: string;
name: string;
static_link: string;
static_link?: string;
url?: string;
};
// Default categories removed — component now accepts `categories` via props.
const resolveHref = (category: Category) => category.static_link ?? category.url ?? "#";
const ListCategory: React.FC<{ categories?: Category[] }> = ({
categories = [],
}) => {
const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }) => {
const pathname = usePathname() || "";
const isActive = (href: string) => {
return pathname === href;
};
const isActive = (href: string) => pathname === href;
return (
<div className="border-t border-gray-200 bg-white py-2">
<div className="w-full px-4 sm:px-6 lg:px-8">
<div className="py-3">
<div className="flex flex-wrap items-center max-w-full overflow-x-auto">
{categories.map((c) => {
const menu = { id: c.id, name: c.name, link: c.static_link };
const active = isActive(c.static_link);
{categories.map((category) => {
const href = resolveHref(category);
const menu = { id: category.id, name: category.name, link: href };
const active = isActive(href);
return (
<div key={c.id} className="shrink-0">
<div key={category.id} className="shrink-0">
<MenuItem menu={menu} active={active} />
</div>
);
......
"use client"
import React, { useState, useEffect } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
"use client";
type Category = { id: string; title: string; count: number }
import React, { useEffect, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
type Category = { id: string; title: string; count: number };
export const ListFilter: React.FC<{
categories?: Category[]
onSearch?: (q: string) => void
onReset?: () => void
categories?: Category[];
onSearch?: (q: string) => void;
onReset?: () => void;
}> = ({ categories, onSearch, onReset }) => {
const [query, setQuery] = useState('')
const [visibleCount, setVisibleCount] = useState(5)
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(5);
const [selected, setSelected] = useState<Record<string, boolean>>(() => {
const map: Record<string, boolean> = {}
if (categories && categories.length) {
categories.forEach((c: Category) => (map[c.id] = false))
const map: Record<string, boolean> = {};
if (categories?.length) {
categories.forEach((category) => {
map[category.id] = false;
});
}
return map
})
return map;
});
// Keep selected map in sync when categories prop changes.
// Defer setSelected to avoid calling setState synchronously inside the effect.
useEffect(() => {
const timer = setTimeout(() => {
setSelected((prev) => {
const map: Record<string, boolean> = {}
if (categories && categories.length) {
categories.forEach((c: Category) => (map[c.id] = !!prev[c.id]))
const map: Record<string, boolean> = {};
if (categories?.length) {
categories.forEach((category) => {
map[category.id] = Boolean(prev[category.id]);
});
}
return map
})
}, 0)
return () => clearTimeout(timer)
}, [categories])
return map;
});
}, 0);
return () => clearTimeout(timer);
}, [categories]);
const toggle = (id: string) => setSelected((s) => ({ ...s, [id]: !s[id] }))
const toggle = (id: string) => setSelected((current) => ({ ...current, [id]: !current[id] }));
return (
<aside className="p-6 bg-white border rounded-md">
......@@ -44,74 +48,82 @@ export const ListFilter: React.FC<{
<div className="mb-4">
<Input
placeholder="Tên văn bản ..."
placeholder="Tên văn bản..."
value={query}
className='text-black placeholder:text-gray-400 rounded-none py-2.5 px-2'
className="text-black placeholder:text-gray-400 rounded-none py-2.5 px-2"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onSearch?.(query)
if (e.key === "Enter") {
onSearch?.(query);
}
}}
/>
</div>
<div className="flex flex-col gap-3">
{categories && categories.length > 0 ? (
categories.slice(0, visibleCount).map((c) => (
<label key={c.id} className="flex items-center gap-3">
<Checkbox checked={!!selected[c.id]} onCheckedChange={() => toggle(c.id)} />
<div className="flex justify-between w-full items-center">
<span className="text-sm">{c.title}</span>
<span className="text-sm text-gray-400">({c.count})</span>
</div>
</label>
))
) : null}
{categories?.length
? categories.slice(0, visibleCount).map((category) => (
<label key={category.id} className="flex items-center gap-3">
<Checkbox
checked={Boolean(selected[category.id])}
onCheckedChange={() => toggle(category.id)}
/>
<div className="flex justify-between w-full items-center">
<span className="text-sm">{category.title}</span>
<span className="text-sm text-gray-400">({category.count})</span>
</div>
</label>
))
: null}
<div className="mt-2 flex items-center gap-3">
{(categories?.length ?? 0) > visibleCount && (
{(categories?.length ?? 0) > visibleCount ? (
<button
className="text-sm text-primary self-start"
onClick={() => setVisibleCount((v) => v + 5)}
onClick={() => setVisibleCount((current) => current + 5)}
>
Xem thêm
</button>
)}
) : null}
{visibleCount > 5 && (
{visibleCount > 5 ? (
<button
className="text-sm text-gray-500 self-start"
onClick={() => setVisibleCount(5)}
>
Thu gọn
</button>
)}
) : null}
</div>
</div>
<div className="flex gap-3">
<Button className="flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary" onClick={() => onSearch?.(query)}>
<Button
className="flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
onClick={() => onSearch?.(query)}
>
Tìm kiếm
</Button>
<Button
className="flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
onClick={() => {
setQuery('')
// restore initial map
const map: Record<string, boolean> = {}
if (categories && categories.length) {
categories.forEach((c) => (map[c.id] = false))
}
setSelected(map)
setVisibleCount(5)
onReset?.()
}}
onClick={() => {
setQuery("");
const map: Record<string, boolean> = {};
if (categories?.length) {
categories.forEach((category) => {
map[category.id] = false;
});
}
setSelected(map);
setVisibleCount(5);
onReset?.();
}}
>
Bỏ tìm
</Button>
</div>
</aside>
)
}
);
};
export default ListFilter
export default ListFilter;
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ensureValidAdminAccessToken } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore";
const LOGIN_PATH = "/admin/login";
......@@ -71,20 +72,62 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const accessToken = useAuthStore((state) => state.appAccessToken);
const accessTokenExpired = useAuthStore((state) => state.appAccessTokenExpired);
const refreshToken = useAuthStore((state) => state.appRefreshToken);
const isRefreshing = useAuthStore((state) => state.appIsRefreshing);
const [isRestoringSession, setIsRestoringSession] = useState(false);
useEffect(() => {
if (!hasHydrated || pathname === LOGIN_PATH) return;
if (!isLoggedIn || !accessToken) {
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
}
}, [accessToken, hasHydrated, isLoggedIn, pathname, router]);
let cancelled = false;
const restoreSession = async () => {
const needsRefresh = Boolean(
accessToken &&
accessTokenExpired &&
accessTokenExpired <= Date.now() &&
refreshToken,
);
if (accessToken && isLoggedIn && !needsRefresh) return;
if (!refreshToken) {
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
return;
}
setIsRestoringSession(true);
try {
const nextToken = await ensureValidAdminAccessToken();
if (!nextToken && !cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
}
} catch {
if (!cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${encodeURIComponent(pathname)}`);
}
} finally {
if (!cancelled) {
setIsRestoringSession(false);
}
}
};
void restoreSession();
return () => {
cancelled = true;
};
}, [accessToken, accessTokenExpired, hasHydrated, isLoggedIn, pathname, refreshToken, router]);
if (pathname === LOGIN_PATH) {
return <>{children}</>;
}
if (!hasHydrated) {
if (!hasHydrated || isRefreshing || isRestoringSession) {
return <AdminAuthLoadingScreen />;
}
......
'use client';
import React from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { LogOut, Menu, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { deleteAuthLogout } from '@/api/endpoints/auth';
import { logoutAdmin } from '@/lib/auth/admin-auth';
import { useSidebarStore } from '@/hooks/use-admin-sidebar';
import useAuthStore from '@/store/useAuthStore';
......@@ -40,23 +39,30 @@ function getTitle(pathname: string): string {
return 'Quản trị';
}
function formatPrimaryRole(role?: string) {
if (!role) return currentUserRoleLabel;
return role
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function formatRoles(roles?: string[]) {
if (!roles || roles.length === 0) return currentUserRoleLabel;
return roles.map((role) => formatPrimaryRole(role)).join(', ');
}
export function AdminHeader() {
const { toggle } = useSidebarStore();
const pathname = usePathname();
const router = useRouter();
const title = getTitle(pathname);
const resetStore = useAuthStore((state) => state.resetStore);
const currentUser = useAuthStore((state) => state.appUser);
const handleLogout = async () => {
try {
await deleteAuthLogout();
} catch {
// Ignore API logout failure and continue clearing local state.
} finally {
resetStore();
toast.success('Đã đăng xuất khỏi trang quản trị');
router.replace('/admin/login');
}
await logoutAdmin({ redirectToLogin: true });
};
return (
......@@ -78,7 +84,7 @@ export function AdminHeader() {
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 rounded-full border border-[#063e8e]/10 bg-[#f8fbff] px-3 py-1.5 text-sm font-medium text-[#163b73]">
<ShieldCheck className="h-4 w-4 text-[#063e8e]" />
<span>{currentUserRoleLabel}</span>
<span>{formatRoles(currentUser?.roles)}</span>
</div>
<Button
variant="outline"
......
......@@ -29,7 +29,7 @@ const AppEditorContent: FC<AppEditorContentProps> = ({ value = '', className = '
// 3. ✅ Xóa thẻ <a> nhưng giữ lại nội dung
if (tagName === 'a') {
// Trả về children đã được xử lý (làm phẳng thẻ <a>)
// Tr? v? children d? du?c x? l? (l?m ph?ng th? <a>)
return <>{children}</>;
}
......
......@@ -2,7 +2,6 @@
import { useCustomClient } from "@/api/mutator/custom-client";
import { categoryFallbackRows } from "@/mockdata/categories";
import useAuthStore from "@/store/useAuthStore";
export type CmsHeaderCategoryType = "category" | "page" | "news";
......@@ -189,16 +188,11 @@ const readMessage = (payload: unknown) => {
const authHeaders = (withJson = true) => {
const headers = new Headers();
const token = useAuthStore.getState().appAccessToken;
if (withJson) {
headers.set("Content-Type", "application/json");
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers;
};
......@@ -341,7 +335,9 @@ const transformPost = (
slug: post.slug ?? "",
summary: post.summary ?? "",
type:
primaryCategoryType === "post" || primaryCategoryType === "page"
post.type === "page" ||
primaryCategoryType === "post" ||
primaryCategoryType === "page"
? "baiviettrang"
: "tintuc",
header_category_id: primaryCategory?.id ?? "",
......@@ -550,6 +546,29 @@ export async function fetchCmsTags() {
return fetchAllTagsInternal();
}
export async function fetchCmsTagsPage(params?: {
page?: number;
pageSize?: number;
}) {
const searchParams = new URLSearchParams({
page: String(params?.page ?? 1),
pageSize: String(params?.pageSize ?? 10),
sortField: "name",
sortOrder: "ASC",
});
const result = await cmsRequest<CmsPagedResult<CmsTagItem>>(
`/tag?${searchParams.toString()}`,
);
return {
items: result.rows ?? [],
total: result.count ?? 0,
page: result.page ?? params?.page ?? 1,
pageSize: result.pageSize ?? params?.pageSize ?? 10,
};
}
export async function createCmsTag(input: { name: string; slug?: string }) {
return cmsRequest<CmsTagItem>("/tag", {
method: "POST",
......@@ -723,6 +742,7 @@ export async function fetchCmsNewsItems(params?: {
pageSize?: number;
sortField?: string;
sortOrder?: string;
filters?: string;
}) {
const queryParams = new URLSearchParams({
page: String(params?.page ?? 1),
......@@ -731,6 +751,10 @@ export async function fetchCmsNewsItems(params?: {
sortOrder: params?.sortOrder ?? "desc",
});
if (params?.filters?.trim()) {
queryParams.set("filters", params.filters.trim());
}
const result = await cmsRequest<CmsPagedResult<CmsRawPostItem>>(
`/post?${queryParams.toString()}`,
);
......@@ -757,6 +781,7 @@ export async function createCmsNewsItem(input: {
title: string;
slug: string;
summary: string;
type: "tintuc" | "baiviettrang";
header_category_id: string;
category_ids: string[];
tag_ids: string[];
......@@ -776,6 +801,7 @@ export async function createCmsNewsItem(input: {
title: input.title,
slug: input.slug,
summary: input.summary,
type: input.type === "baiviettrang" ? "page" : "news",
external_link: input.slug ? `/${input.slug}` : "/",
content: input.summary || "",
category_ids: input.category_ids,
......@@ -816,6 +842,7 @@ export async function updateCmsNewsItem(
title: string;
slug: string;
summary: string;
type: "tintuc" | "baiviettrang";
header_category_id: string;
category_ids: string[];
tag_ids: string[];
......@@ -836,6 +863,7 @@ export async function updateCmsNewsItem(
title: input.title,
slug: input.slug,
summary: input.summary,
type: input.type === "baiviettrang" ? "page" : "news",
external_link: input.slug ? `/${input.slug}` : "/",
content: input.summary || "",
category_ids: input.category_ids,
......
This diff is collapsed.
This diff is collapsed.
......@@ -257,7 +257,7 @@ export const categoryFallbackRows: Category[] = [
id: "142c9525-b206-4b87-8978-4b7048a46a3b",
name: "Trang chủ",
slug: "trang-chu",
url: "/trang-chu",
url: "/",
sort_order: 1,
created_at: "2026-05-14T04:54:26.127Z",
created_by: null,
......
This diff is collapsed.
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