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

fix

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