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

fix

parent 330d67d3
......@@ -15,7 +15,7 @@ function BusinessOpportunities() {
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh";
return (
<section className="flex-1">
<section className="flex flex-1 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
......@@ -32,7 +32,7 @@ function BusinessOpportunities() {
</Link>
</div>
<div className="space-y-3">
<div className="flex min-h-[270px] flex-1 flex-col justify-between gap-3">
{featuredItem ? (
<Link
href={featuredItem.externalLink}
......@@ -52,13 +52,13 @@ function BusinessOpportunities() {
</div>
)}
<div className="space-y-2.5">
<div className="flex flex-col gap-2.5">
{listSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className="flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
className="flex min-h-[58px] gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]" />
<div className="min-w-0">
......@@ -73,7 +73,7 @@ function BusinessOpportunities() {
) : (
<div
key={`business-placeholder-${index}`}
className="flex gap-3 rounded-[14px] px-0.5 py-1"
className="flex min-h-[58px] 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">
......
......@@ -8,32 +8,37 @@ import { useMemo, useState } from "react";
const weekDays = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
const formatDateTime = (value: string) =>
value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "Đang cập nhật";
const isTrainingEvent = (item: HomePostItem) =>
item.categories.some((category) => {
const key = `${category.name} ${category.slug} ${category.url}`.toLowerCase();
return key.includes("đào tạo") || key.includes("dao-tao");
});
function EventsCalendar() {
const { eventPosts } = useHomePosts();
const { eventCalendarPosts } = useHomePosts();
const firstEventDate = eventPosts[0]?.startedAt
? new Date(eventPosts[0].startedAt)
: new Date("2026-11-01T00:00:00");
const firstEventDate = eventCalendarPosts[0]?.registrationDeadline
? new Date(eventCalendarPosts[0].registrationDeadline)
: new Date();
const [currentMonth, setCurrentMonth] = useState(
new Date(firstEventDate.getFullYear(), firstEventDate.getMonth(), 1),
);
const isTrainingEvent = (item: HomePostItem) =>
item.categories.some((category) =>
category.name.toLowerCase().includes("đào tạo"),
);
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
const monthEvents = useMemo(
() =>
eventPosts.filter((item) => {
const date = new Date(item.startedAt);
eventCalendarPosts.filter((item) => {
const date = new Date(item.registrationDeadline);
return (
date.getMonth() === currentMonth.getMonth() &&
date.getFullYear() === currentMonth.getFullYear()
);
}),
[currentMonth, eventPosts],
[currentMonth, eventCalendarPosts],
);
const days = useMemo(() => {
......@@ -53,7 +58,7 @@ function EventsCalendar() {
const map = new Map<string, HomePostItem[]>();
monthEvents.forEach((item) => {
const key = dayjs(item.startedAt).format("YYYY-MM-DD");
const key = dayjs(item.registrationDeadline).format("YYYY-MM-DD");
const existing = map.get(key) ?? [];
existing.push(item);
map.set(key, existing);
......@@ -62,7 +67,8 @@ function EventsCalendar() {
return map;
}, [monthEvents]);
const highlightedEvent = monthEvents[0];
const selectedEvents = selectedDateKey ? eventMap.get(selectedDateKey) ?? [] : [];
const highlightedEvent = selectedEvents[0] ?? monthEvents[0];
return (
<aside className="w-full rounded-[28px] bg-white p-4 text-[#24469c] shadow-[0_18px_38px_rgba(16,61,130,0.16)] md:p-5 xl:w-[28%] xl:min-w-[320px]">
......@@ -109,11 +115,21 @@ function EventsCalendar() {
const inMonth = day.getMonth() === currentMonth.getMonth();
const hasTraining = items.some((item) => isTrainingEvent(item));
const hasEvent = items.length > 0 && !hasTraining;
const tooltip = items.map((item) => item.title).join("\n");
const selectable = inMonth && items.length > 0;
const selected = selectable && selectedDateKey === key;
return (
<div key={key} className="relative flex items-center justify-center">
<span
className={`relative flex h-7 w-7 items-center justify-center rounded-full ${
<div
key={key}
className="relative flex items-center justify-center"
>
<button
type="button"
title={tooltip || undefined}
disabled={!selectable}
onClick={() => setSelectedDateKey(key)}
className={`relative flex h-7 w-7 items-center justify-center rounded-full transition-all ${
!inMonth
? "text-[#c9d2e2]"
: hasTraining
......@@ -121,10 +137,14 @@ function EventsCalendar() {
: hasEvent
? "bg-[#1e3f9a] font-semibold text-white"
: ""
}`}
} ${
selectable
? "cursor-pointer hover:ring-2 hover:ring-[#f7b500]/60"
: "cursor-default"
} ${selected ? "ring-2 ring-[#f7b500] ring-offset-2" : ""}`}
>
{format(day, "d")}
</span>
</button>
{items.length > 0 && !hasTraining && inMonth ? (
<span className="absolute bottom-[-5px] h-1.5 w-1.5 rounded-full bg-[#1e3f9a]" />
......@@ -154,11 +174,17 @@ function EventsCalendar() {
<div className="mt-4 rounded-[16px] bg-[#f7f9fd] p-3.5 text-[12px] leading-5 text-[#3d547f]">
<div className="flex items-start gap-3">
<span
className={`mt-1 h-2.5 w-2.5 rounded-full ${
className={`mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${
isTrainingEvent(highlightedEvent) ? "bg-[#ffbc11]" : "bg-[#1e3f9a]"
}`}
/>
<p className="line-clamp-3">{highlightedEvent.title}</p>
<div className="min-w-0 space-y-1">
<p>
Hạn đăng ký: {formatDateTime(highlightedEvent.registrationDeadline)} · Chi phí:{" "}
{highlightedEvent.participationFee || "Đang cập nhật"}
</p>
<p>Địa điểm: {highlightedEvent.location || "Đang cập nhật"}</p>
</div>
</div>
</div>
) : null}
......
......@@ -8,14 +8,13 @@ import Link from "next/link";
function PolicyAndLaws() {
const { policyPosts, categoryLinks, categoryNames } = useHomePosts();
const policyItems = policyPosts;
const [featuredItem, ...listItems] = policyItems;
const listSlots = [featuredItem, ...listItems.slice(0, 2)];
const listSlots = Array.from({ length: 4 }, (_, index) => policyItems[index] ?? null);
const sectionLink =
categoryLinks.get(categoryNames.chinhSachPhapLuat.toLowerCase()) ??
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat";
return (
<section className="flex-1">
<section className="flex flex-1 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
......@@ -32,13 +31,13 @@ function PolicyAndLaws() {
</Link>
</div>
<div className="space-y-2.5">
<div className="flex min-h-[270px] flex-1 flex-col justify-between gap-2.5">
{listSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className={`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
className={`flex min-h-[58px] gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
index === 0 ? "pt-0.5" : ""
}`}
>
......@@ -55,7 +54,7 @@ function PolicyAndLaws() {
) : (
<div
key={`policy-placeholder-${index}`}
className={`flex gap-3 rounded-[14px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`}
className={`flex min-h-[58px] 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">
......
'use client';
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import ImageNext from "@/components/shared/image-next";
import Link from "next/link";
const quickLinks = [
{
href: "https://vcci-hcm.org.vn/lien-ket-nhanh/doanh-nghiep-kien-nghi-ve-chinh-sach-va-phap-luat/",
label: "Doanh nghiệp kiến nghị về chính sách và pháp luật",
},
{
href: "https://vcci-hcm.org.vn/lien-ket-nhanh/cam-nang-huong-dan-dau-tu-kinh-doanh-tai-viet-nam-2023/",
label: "Cẩm nang hướng dẫn đầu tư kinh doanh tại Việt Nam",
},
];
function QuickLinks() {
const { quickLinkPosts } = useHomePosts();
const linkSlots = Array.from({ length: 4 }, (_, index) => quickLinkPosts[index] ?? null);
return (
<aside className="w-full xl:grid xl:w-[32%] xl:grid-rows-[0.74fr_0.88fr] xl:gap-4">
<div className="rounded-[22px] border border-[#dbe4f2] bg-white p-4 shadow-[0_8px_24px_rgba(31,59,124,0.08)] xl:h-full">
......@@ -23,17 +16,27 @@ function QuickLinks() {
</h2>
<div className="mt-3 h-[5px] w-[68px] rounded-full bg-[#f7b500]" />
<div className="mt-4 space-y-2.5">
{quickLinks.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-start gap-3 text-[15px] leading-[1.32] text-[#556684] transition-colors hover:text-[#21408f]"
>
<span className="mt-1 text-[#e2a500]"></span>
<span>{item.label}</span>
</Link>
))}
<div className="mt-4 space-y-3">
{linkSlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className="flex items-start gap-3 text-[15px] leading-[1.32] text-[#556684] transition-colors hover:text-[#21408f]"
>
<span className="mt-1 text-[#e2a500]"></span>
<span className="line-clamp-1">{item.title}</span>
</Link>
) : (
<div
key={`quick-link-placeholder-${index}`}
className="flex items-start gap-3"
>
<span className="mt-1 h-4 w-2 shrink-0 rounded bg-[#f5d774]" />
<span className="h-5 w-5/6 rounded bg-[#eef3fb]" />
</div>
),
)}
</div>
</div>
......
......@@ -8,6 +8,7 @@ import Links from "@/links";
type RawHomeCategory = {
id?: string | null;
name?: string | null;
slug?: string | null;
url?: string | null;
type?: string | null;
};
......@@ -27,6 +28,10 @@ type RawHomePost = {
published_at?: string | null;
created_at?: string | null;
started_at?: string | null;
ended_at?: string | null;
registration_deadline?: string | null;
location?: string | null;
participation_fee?: string | null;
expired_at?: string | null;
is_featured?: boolean | null;
is_hidden?: boolean | null;
......@@ -48,6 +53,7 @@ type HomePagedResult<T> = {
export type HomePostCategory = {
id: string;
name: string;
slug: string;
url: string;
type: string;
};
......@@ -60,6 +66,10 @@ export type HomePostItem = {
createdAt: string;
publishedAt: string;
startedAt: string;
endedAt: string;
registrationDeadline: string;
location: string;
participationFee: string;
expiredAt: string;
isFeatured: boolean;
isHidden: boolean;
......@@ -73,13 +83,14 @@ export type HomePostItem = {
} | null;
};
const HOME_POSTS_QUERY_KEY = ["home-page-posts"] as const;
const HOME_POSTS_QUERY_KEY = ["home-page-posts", "event-calendar-v1"] as const;
const HOME_CATEGORY_NAMES = {
tinVcci: "Tin VCCI",
tinKinhTe: "Tin Kinh tế",
chuyenDe: "Chuyên đề",
suKien: "Sự kiện",
daoTao: "Đào tạo",
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",
......@@ -87,6 +98,47 @@ const HOME_CATEGORY_NAMES = {
const normalizeText = (value?: string | null) => value?.trim().toLowerCase() ?? "";
const normalizeSearchText = (value?: string | null) =>
normalizeText(value)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/&/g, " va ")
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, " ");
const normalizeSlug = (value?: string | null) =>
normalizeSearchText(value).replace(/\s+/g, "-");
const HOME_CATEGORY_ALIASES = {
tinVcci: ["Tin VCCI", "tin-vcci"],
tinKinhTe: ["Tin Kinh tế", "tin-kinh-te"],
chuyenDe: ["Chuyên đề", "chuyen-de"],
suKien: ["Sự kiện", "su-kien"],
daoTao: ["Đào tạo", "dao-tao"],
coHoiKinhDoanh: ["Cơ hội kinh doanh", "co-hoi-kinh-doanh"],
chinhSachPhapLuat: [
"Thông tin Chính sách và Pháp luật",
"Chính sách và Pháp luật",
"Chính sách & Pháp luật",
"Chính sách Pháp luật",
"thong-tin-chinh-sach-va-phap-luat",
"chinh-sach-va-phap-luat",
"chinh-sach-phap-luat",
],
ketNoiHoiVien: ["Kết nối hội viên", "ket-noi-hoi-vien"],
} as const;
const HOME_CATEGORY_IDS = {
tinVcci: "b89b2ba6-a699-47cb-87e4-0643aea549a9",
tinKinhTe: "755106b6-1aca-47dc-9a9c-d434736c33a1",
chuyenDe: "8e7090e5-bfc3-4128-81a5-37ec78c33bad",
suKien: "b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a",
chinhSachPhapLuat: "cc448be9-b9ea-46a8-aa7b-0584803330e8",
lienKetNhanh: "d7f05384-b1b4-428e-b9b3-37e0e1b0cecd",
} as const;
const normalizeLink = (value?: string | null, fallback = "/") => {
const trimmed = value?.trim();
......@@ -143,6 +195,31 @@ const matchesCategoryName = (item: HomePostItem, categoryName: string) => {
);
};
const matchesCategoryAliases = (
item: HomePostItem,
aliases: readonly string[],
) => {
const aliasKeys = new Set(aliases.map(normalizeSearchText));
const aliasSlugs = new Set(aliases.map(normalizeSlug));
return item.categories.some((category) => {
const categoryNameKey = normalizeSearchText(category.name);
const categorySlugKey = normalizeSlug(category.slug || category.name);
const categoryUrlKey = normalizeSlug(category.url);
const categoryUrlSegments = category.url
.split("/")
.map((segment) => normalizeSlug(segment))
.filter(Boolean);
return (
aliasKeys.has(categoryNameKey) ||
aliasSlugs.has(categorySlugKey) ||
categoryUrlSegments.some((segment) => aliasSlugs.has(segment)) ||
Array.from(aliasSlugs).some((slug) => categoryUrlKey.endsWith(slug))
);
});
};
const isVisibleNewsPost = (item: HomePostItem) => {
if (item.type && item.type !== "news") return false;
if (item.isHidden) return false;
......@@ -151,6 +228,57 @@ const isVisibleNewsPost = (item: HomePostItem) => {
return true;
};
const hasCategoryId = (item: HomePostItem, categoryId: string) =>
item.categories.some((category) => category.id === categoryId);
async function fetchHomePostRows(path: string) {
const response = await useCustomClient<HomeEnvelope<HomePagedResult<RawHomePost>>>(path);
return response.responseData?.rows ?? [];
}
async function fetchHomeCategoryRows() {
const response = await useCustomClient<HomeEnvelope<HomePagedResult<RawHomeCategory>>>(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC",
);
return response.responseData?.rows ?? [];
}
function findCategoryIdByAliases(
categories: RawHomeCategory[],
aliases: readonly string[],
) {
const aliasKeys = new Set(aliases.map(normalizeSearchText));
const aliasSlugs = new Set(aliases.map(normalizeSlug));
return categories.find((category) => {
const categoryNameKey = normalizeSearchText(category.name);
const categorySlugKey = normalizeSlug(category.slug || category.name);
const categoryUrlKey = normalizeSlug(category.url);
return (
aliasKeys.has(categoryNameKey) ||
aliasSlugs.has(categorySlugKey) ||
Array.from(aliasSlugs).some((slug) => categoryUrlKey.endsWith(slug))
);
})?.id ?? null;
}
function createCategoryPostsQuery(categoryId: string, pageSize: string) {
return new URLSearchParams({
page: "1",
pageSize,
sortField: "release_at",
sortOrder: "desc",
filters: [
`category.id==${categoryId}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
].join(","),
});
}
async function fetchHomePosts() {
const query = new URLSearchParams({
page: "1",
......@@ -159,11 +287,51 @@ async function fetchHomePosts() {
sortOrder: "desc",
});
const response = await useCustomClient<HomeEnvelope<HomePagedResult<RawHomePost>>>(
`/post?${query.toString()}`,
const tinVcciQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.tinVcci, "6");
const tinKinhTeQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.tinKinhTe, "6");
const chuyenDeQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.chuyenDe, "6");
const eventQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.suKien, "5");
const policyQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.chinhSachPhapLuat, "6");
const quickLinksQuery = createCategoryPostsQuery(HOME_CATEGORY_IDS.lienKetNhanh, "6");
const categoryRows = await fetchHomeCategoryRows().catch(() => []);
const trainingCategoryId = findCategoryIdByAliases(
categoryRows,
HOME_CATEGORY_ALIASES.daoTao,
);
const rows = response.responseData?.rows ?? [];
const trainingQuery = trainingCategoryId
? createCategoryPostsQuery(String(trainingCategoryId), "20")
: null;
const [
homeRows,
tinVcciRows,
tinKinhTeRows,
chuyenDeRows,
policyRows,
eventRows,
quickLinkRows,
trainingRows,
] = await Promise.all([
fetchHomePostRows(`/post?${query.toString()}`),
fetchHomePostRows(`/post?${tinVcciQuery.toString()}`),
fetchHomePostRows(`/post?${tinKinhTeQuery.toString()}`),
fetchHomePostRows(`/post?${chuyenDeQuery.toString()}`),
fetchHomePostRows(`/post?${policyQuery.toString()}`),
fetchHomePostRows(`/post?${eventQuery.toString()}`),
fetchHomePostRows(`/post?${quickLinksQuery.toString()}`),
trainingQuery ? fetchHomePostRows(`/post?${trainingQuery.toString()}`) : [],
]);
const rows = [
...tinVcciRows,
...tinKinhTeRows,
...chuyenDeRows,
...policyRows,
...eventRows,
...trainingRows,
...quickLinkRows,
...homeRows,
];
return rows
.map<HomePostItem>((item) => {
......@@ -172,6 +340,7 @@ async function fetchHomePosts() {
.map((category) => ({
id: String(category.id),
name: String(category.name),
slug: String(category.slug ?? ""),
url: normalizeLink(category.url, "#"),
type: String(category.type ?? ""),
}));
......@@ -191,6 +360,10 @@ async function fetchHomePosts() {
createdAt: String(item.created_at ?? ""),
publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""),
startedAt: String(item.started_at ?? ""),
endedAt: String(item.ended_at ?? ""),
registrationDeadline: String(item.registration_deadline ?? ""),
location: String(item.location ?? ""),
participationFee: String(item.participation_fee ?? ""),
expiredAt: String(item.expired_at ?? ""),
isFeatured: Boolean(item.is_featured),
isHidden: Boolean(item.is_hidden),
......@@ -242,7 +415,9 @@ export function useHomePosts() {
const tinVcciPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => matchesCategoryName(item, HOME_CATEGORY_NAMES.tinVcci)),
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.tinVcci),
),
),
[posts],
);
......@@ -250,7 +425,9 @@ export function useHomePosts() {
const tinKinhTePosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => matchesCategoryName(item, HOME_CATEGORY_NAMES.tinKinhTe)),
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.tinKinhTe),
),
),
[posts],
);
......@@ -258,7 +435,9 @@ export function useHomePosts() {
const chuyenDePosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => matchesCategoryName(item, HOME_CATEGORY_NAMES.chuyenDe)),
posts.filter((item) =>
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.chuyenDe),
),
),
[posts],
);
......@@ -276,8 +455,20 @@ export function useHomePosts() {
sortByEventStartAsc(
posts.filter(
(item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.suKien) &&
Boolean(item.startedAt),
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.suKien),
),
),
[posts],
);
const eventCalendarPosts = React.useMemo(
() =>
sortByEventStartAsc(
posts.filter(
(item) =>
Boolean(item.registrationDeadline) &&
(matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.suKien) ||
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.daoTao)),
),
),
[posts],
......@@ -287,7 +478,7 @@ export function useHomePosts() {
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.coHoiKinhDoanh),
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.coHoiKinhDoanh),
),
),
[posts],
......@@ -297,7 +488,7 @@ export function useHomePosts() {
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.chinhSachPhapLuat),
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.chinhSachPhapLuat),
),
),
[posts],
......@@ -307,12 +498,20 @@ export function useHomePosts() {
() =>
sortByPublishedDesc(
posts.filter((item) =>
matchesCategoryName(item, HOME_CATEGORY_NAMES.ketNoiHoiVien),
matchesCategoryAliases(item, HOME_CATEGORY_ALIASES.ketNoiHoiVien),
),
),
[posts],
);
const quickLinkPosts = React.useMemo(
() =>
sortByPublishedDesc(
posts.filter((item) => hasCategoryId(item, HOME_CATEGORY_IDS.lienKetNhanh)),
),
[posts],
);
const allNewsPosts = React.useMemo(
() => uniquePosts([...tinVcciPosts, ...tinKinhTePosts, ...chuyenDePosts]),
[chuyenDePosts, tinKinhTePosts, tinVcciPosts],
......@@ -324,9 +523,11 @@ export function useHomePosts() {
posts,
featuredPosts,
eventPosts,
eventCalendarPosts,
businessPosts,
policyPosts,
memberConnectionPosts,
quickLinkPosts,
newsTabs: {
all: allNewsPosts,
tinVcci: tinVcciPosts,
......
'use client'
import { AppProgressBar as ProgressBar } from 'next-nprogress-bar'
import { Fragment, startTransition, useEffect, useState } from 'react'
import { cssVar } from '@/lib/utils/css-var'
export const ProgressBarProvider = ({ children }: { children: React.ReactNode }) => {
const [isClient, setIsClient] = useState(false)
useEffect(() => startTransition(() => setIsClient(true)), [])
if (!isClient) return children
return (
<Fragment>
{children}
<ProgressBar
height='4px'
color={`hsl(${cssVar('--secondary')})`}
options={{ showSpinner: false }}
shallowRouting
/>
</Fragment>
)
}
'use client'
import { AppProgressBar as ProgressBar } from 'next-nprogress-bar'
import { Fragment, startTransition, useEffect, useState } from 'react'
import { cssVar } from '@/lib/utils/css-var'
export const ProgressBarProvider = ({ children }: { children: React.ReactNode }) => {
const [isClient, setIsClient] = useState(false)
useEffect(() => startTransition(() => setIsClient(true)), [])
return (
<Fragment>
{children}
{isClient ? (
<ProgressBar
height='4px'
color={`hsl(${cssVar('--secondary')})`}
options={{ showSpinner: false }}
shallowRouting
/>
) : null}
</Fragment>
)
}
export default ProgressBarProvider
......@@ -2,35 +2,50 @@
import React from 'react';
import { usePathname } from 'next/navigation';
import { AdminAuthGuard } from '@/components/shared/admin-auth-guard';
import {
AdminAuthLoadingScreen,
useAdminAuthStatus,
} from '@/components/shared/admin-auth-guard';
import { AdminSidebar } from '@/components/shared/admin-sidebar';
import { AdminHeader } from '@/components/shared/admin-header';
import { useSidebarStore } from '@/hooks/use-admin-sidebar';
import { cn } from '@/lib/utils';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
function AdminShell({ children }: { children: React.ReactNode }) {
const { isOpen } = useSidebarStore();
const pathname = usePathname();
const isLoginPage = pathname === '/admin/login';
return (
<AdminAuthGuard>
{isLoginPage ? (
<div className="min-h-screen bg-slate-50">{children}</div>
) : (
<div className="min-h-screen bg-white">
<AdminSidebar />
<div
className={cn(
'transition-all duration-300',
isOpen ? 'pl-72' : 'pl-24',
)}
>
<AdminHeader />
<main className="px-4 py-4 lg:px-6 lg:py-6">{children}</main>
</div>
</div>
)}
</AdminAuthGuard>
<div className="min-h-screen bg-white">
<AdminSidebar />
<div
className={cn(
'transition-all duration-300',
isOpen ? 'pl-72' : 'pl-24',
)}
>
<AdminHeader />
<main className="px-4 py-4 lg:px-6 lg:py-6">{children}</main>
</div>
</div>
);
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isLoginPage = pathname === '/admin/login';
const authStatus = useAdminAuthStatus();
if (isLoginPage) {
return <div className="min-h-screen bg-slate-50">{children}</div>;
}
if (authStatus === 'loading') {
return <AdminAuthLoadingScreen />;
}
if (authStatus === 'blocked') {
return null;
}
return <AdminShell>{children}</AdminShell>;
}
......@@ -125,8 +125,8 @@ function AuthShell({
mode === "login"
? "Truy cập khu vực quản trị nội dung VCCI News."
: mode === "forgot"
? "X?c th?c email qu?n tr? d? nh?n m? OTP."
: "Nh?p m? OTP v? t?o m?t kh?u m?i cho t?i kho?n.";
? "Xác thực email quản trị để nhận mã OTP."
: "Nhập mã OTP để tạo mật khẩu mới cho tài khoản.";
return (
<div className="min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700">
......@@ -312,7 +312,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setLoginLoading(true);
try {
await loginAdmin(email.trim(), password);
await loginAdmin(email.trim(), password, { persistSession: remember });
setAppUserRemember(remember ? email.trim() : "", remember ? password : "", remember);
toast.success("Đăng nhập quản trị thành công");
......@@ -543,7 +543,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
Đang gửi OTP...
</>
) : (
"G?i m? OTP"
"Gửi mã OTP"
)}
</Button>
......
import { redirect } from 'next/navigation';
export default function AdminPage() {
redirect('/admin/login');
redirect('/admin/base-config');
}
......@@ -7,7 +7,7 @@ import useAuthStore from "@/store/useAuthStore";
const LOGIN_PATH = "/admin/login";
function AdminAuthLoadingScreen() {
export function AdminAuthLoadingScreen() {
return (
<div className="min-h-screen bg-white">
<div className="fixed inset-y-0 left-0 hidden w-24 border-r border-[#063e8e]/10 bg-white lg:block">
......@@ -76,7 +76,10 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const refreshToken = useAuthStore((state) => state.appRefreshToken);
const isRefreshing = useAuthStore((state) => state.appIsRefreshing);
const [authCheckState, setAuthCheckState] = useState<"idle" | "checking" | "ready">("idle");
const redirectParam = encodeURIComponent(pathname || "/admin");
const redirectParam =
typeof window === "undefined"
? encodeURIComponent(pathname || "/admin")
: encodeURIComponent(`${window.location.pathname}${window.location.search}`);
useEffect(() => {
if (pathname === LOGIN_PATH) {
......@@ -156,9 +159,117 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
return <AdminAuthLoadingScreen />;
}
if (!isLoggedIn || (!accessToken && refreshToken)) {
return <AdminAuthLoadingScreen />;
}
if (!isLoggedIn || !accessToken) {
return null;
}
return <>{children}</>;
}
export function useAdminAuthStatus() {
const router = useRouter();
const pathname = usePathname();
const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const accessToken = useAuthStore((state) => state.appAccessToken);
const accessTokenExpired = useAuthStore((state) => state.appAccessTokenExpired);
const refreshToken = useAuthStore((state) => state.appRefreshToken);
const isRefreshing = useAuthStore((state) => state.appIsRefreshing);
const [authCheckState, setAuthCheckState] = useState<"idle" | "checking" | "ready">("idle");
const redirectParam =
typeof window === "undefined"
? encodeURIComponent(pathname || "/admin")
: encodeURIComponent(`${window.location.pathname}${window.location.search}`);
useEffect(() => {
if (pathname === LOGIN_PATH) {
setAuthCheckState("ready");
return;
}
if (!hasHydrated) {
setAuthCheckState("idle");
return;
}
let cancelled = false;
const restoreSession = async () => {
setAuthCheckState("checking");
const hasValidAccessToken = Boolean(
accessToken &&
isLoggedIn &&
(!accessTokenExpired || accessTokenExpired > Date.now()),
);
if (hasValidAccessToken) {
if (!cancelled) {
setAuthCheckState("ready");
}
return;
}
if (!refreshToken) {
if (!cancelled) {
setAuthCheckState("ready");
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
}
return;
}
try {
const nextToken = await ensureValidAdminAccessToken();
if (!nextToken && !cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
}
} catch {
if (!cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
}
} finally {
if (!cancelled) {
setAuthCheckState("ready");
}
}
};
void restoreSession();
return () => {
cancelled = true;
};
}, [
accessToken,
accessTokenExpired,
hasHydrated,
isLoggedIn,
pathname,
redirectParam,
refreshToken,
router,
]);
if (pathname === LOGIN_PATH) {
return "ready" as const;
}
if (!hasHydrated || isRefreshing || authCheckState !== "ready") {
return "loading" as const;
}
if (!isLoggedIn || (!accessToken && refreshToken)) {
return "loading" as const;
}
if (!isLoggedIn || !accessToken) {
return "blocked" as const;
}
return "ready" as const;
}
......@@ -38,7 +38,7 @@ interface LoginResponseData {
token_type?: string | null;
}
interface MeResponseData extends Partial<AuthenticatedAdminUser> {}
type MeResponseData = Partial<AuthenticatedAdminUser>;
interface RefreshResponseData {
session?: Partial<AuthenticatedAdminSession> | null;
......@@ -57,6 +57,7 @@ type AuthFailureReason = "missing_refresh_token" | "refresh_failed";
let refreshPromise: Promise<string | null> | null = null;
let forcedLogoutPromise: Promise<void> | null = null;
const ACCESS_TOKEN_EXPIRY_SKEW_SECONDS = 300;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
......@@ -107,6 +108,27 @@ const normalizeSession = (
};
};
const getJwtExpiresAt = (token?: string | null) => {
if (!token) return null;
const [, payload] = token.split(".");
if (!payload) return null;
try {
const normalizedPayload = payload.replace(/-/g, "+").replace(/_/g, "/");
const decoded =
typeof window === "undefined"
? Buffer.from(normalizedPayload, "base64").toString("utf8")
: window.atob(normalizedPayload.padEnd(Math.ceil(normalizedPayload.length / 4) * 4, "="));
const parsed = JSON.parse(decoded) as { exp?: unknown };
const exp = typeof parsed.exp === "number" ? parsed.exp : null;
return exp ? (exp - ACCESS_TOKEN_EXPIRY_SKEW_SECONDS) * 1000 : null;
} catch {
return null;
}
};
async function requestAuth<T>(
path: string,
init?: AuthRequestOptions,
......@@ -163,7 +185,11 @@ const markSessionExpiredAndNotify = () => {
}
};
export async function loginAdmin(email: string, password: string) {
export async function loginAdmin(
email: string,
password: string,
options?: { persistSession?: boolean },
) {
const payload = await requestAuth<LoginResponseData>("/login", {
method: "POST",
body: JSON.stringify({
......@@ -188,8 +214,10 @@ export async function loginAdmin(email: string, password: string) {
accessToken: payload.access_token,
refreshToken: payload.refresh_token,
expiresIn: payload.expires_in,
accessTokenExpired: getJwtExpiresAt(payload.access_token),
user: normalizedUser,
session: normalizeSession(payload.session),
persistSession: options?.persistSession === true,
});
useAuthStore.getState().setAppUser(normalizedUser);
......@@ -276,6 +304,7 @@ export async function refreshAdminAccessToken() {
useAuthStore.getState().updateAccessToken({
accessToken: payload.access_token,
expiresIn: payload.expires_in,
accessTokenExpired: getJwtExpiresAt(payload.access_token),
refreshToken: payload.refresh_token ?? refreshToken,
session: normalizeSession(payload.session),
});
......@@ -305,7 +334,11 @@ export async function ensureValidAdminAccessToken() {
}
if (!store.appAccessTokenExpired || store.appAccessTokenExpired > Date.now()) {
return store.appAccessToken;
const jwtExpiresAt = getJwtExpiresAt(store.appAccessToken);
if (!jwtExpiresAt || jwtExpiresAt > Date.now()) {
return store.appAccessToken;
}
}
return refreshAdminAccessToken();
......
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { createJSONStorage, devtools, persist } from "zustand/middleware";
export interface AuthenticatedAdminUser {
id: string;
......@@ -23,13 +23,16 @@ export interface AuthSessionPayload {
accessToken: string;
refreshToken: string;
expiresIn: number;
accessTokenExpired?: number | null;
user: AuthenticatedAdminUser | null;
session: AuthenticatedAdminSession | null;
persistSession?: boolean;
}
export interface AuthRefreshPayload {
accessToken: string;
expiresIn: number;
accessTokenExpired?: number | null;
refreshToken?: string | null;
session?: AuthenticatedAdminSession | null;
}
......@@ -41,6 +44,7 @@ export interface AuthStoreStateType {
appRefreshToken: string | null;
appSession: AuthenticatedAdminSession | null;
appUser: AuthenticatedAdminUser | null;
appPersistSession: boolean;
appIsRefreshing: boolean;
appSessionExpiredNotified: boolean;
appUserRemember: {
......@@ -62,8 +66,17 @@ export interface AuthStoreStateType {
resetStore: () => void;
}
const ACCESS_TOKEN_EXPIRY_SKEW_SECONDS = 300;
const getAccessTokenExpiredAt = (expiresIn: number) =>
Date.now() + Math.max(expiresIn - 300, 0) * 1000;
Date.now() + Math.max(expiresIn - ACCESS_TOKEN_EXPIRY_SKEW_SECONDS, 0) * 1000;
const getRefreshTokenExpiredAt = (session?: AuthenticatedAdminSession | null) => {
if (!session?.refresh_expires_at) return null;
const time = new Date(session.refresh_expires_at).getTime();
return Number.isFinite(time) ? time : null;
};
const baseState = {
appIsLoggedIn: false,
......@@ -72,6 +85,7 @@ const baseState = {
appRefreshToken: null,
appSession: null,
appUser: null,
appPersistSession: false,
appIsRefreshing: false,
appSessionExpiredNotified: false,
appUserRemember: null,
......@@ -85,10 +99,67 @@ const clearSessionState = {
appRefreshToken: null,
appSession: null,
appUser: null,
appPersistSession: false,
appIsRefreshing: false,
appSessionExpiredNotified: false,
};
const normalizePersistedAuthState = (
persistedState: unknown,
currentState: AuthStoreStateType,
) => {
const storageState =
typeof persistedState === "object" &&
persistedState !== null &&
"state" in persistedState &&
typeof (persistedState as { state?: unknown }).state === "object" &&
(persistedState as { state?: unknown }).state !== null
? (persistedState as { state: unknown }).state
: persistedState;
const persisted =
typeof storageState === "object" && storageState !== null
? (storageState as Partial<AuthStoreStateType>)
: {};
const rememberState = persisted.appUserRemember ?? currentState.appUserRemember;
const persistSession = persisted.appPersistSession === true;
const refreshTokenExpiredAt = getRefreshTokenExpiredAt(persisted.appSession ?? null);
const hasUsableSession =
persistSession &&
Boolean(persisted.appRefreshToken) &&
(!refreshTokenExpiredAt || refreshTokenExpiredAt > Date.now()) &&
(!persisted.appAccessTokenExpired ||
typeof persisted.appAccessTokenExpired === "number");
if (!hasUsableSession) {
return {
...currentState,
...clearSessionState,
appUserRemember: rememberState,
};
}
return {
...currentState,
...persisted,
appPersistSession: true,
appUserRemember: rememberState,
appIsRefreshing: false,
};
};
const shouldPersistAuthStorage = (value: string) => {
try {
const parsed = JSON.parse(value) as {
state?: Partial<AuthStoreStateType>;
};
const state = parsed.state ?? {};
return state.appPersistSession === true || state.appUserRemember?.remember === true;
} catch {
return true;
}
};
const useAuthStore = create<AuthStoreStateType>()(
devtools(
persist(
......@@ -102,22 +173,31 @@ const useAuthStore = create<AuthStoreStateType>()(
set(() => ({
appIsLoggedIn: isLoggedIn,
})),
setAuthSession: ({ accessToken, refreshToken, expiresIn, user, session }) =>
setAuthSession: ({
accessToken,
refreshToken,
expiresIn,
accessTokenExpired,
user,
session,
persistSession = false,
}) =>
set(() => ({
appIsLoggedIn: true,
appAccessToken: accessToken,
appAccessTokenExpired: getAccessTokenExpiredAt(expiresIn),
appAccessTokenExpired: accessTokenExpired ?? getAccessTokenExpiredAt(expiresIn),
appRefreshToken: refreshToken,
appSession: session,
appUser: user,
appPersistSession: persistSession,
appIsRefreshing: false,
appSessionExpiredNotified: false,
})),
updateAccessToken: ({ accessToken, expiresIn, refreshToken, session }) =>
updateAccessToken: ({ accessToken, expiresIn, accessTokenExpired, refreshToken, session }) =>
set(() => ({
appIsLoggedIn: true,
appAccessToken: accessToken,
appAccessTokenExpired: getAccessTokenExpiredAt(expiresIn),
appAccessTokenExpired: accessTokenExpired ?? getAccessTokenExpiredAt(expiresIn),
appRefreshToken: refreshToken ?? get().appRefreshToken,
appSession: session ?? get().appSession,
appIsRefreshing: false,
......@@ -151,11 +231,14 @@ const useAuthStore = create<AuthStoreStateType>()(
},
setAppUserRemember: (username, password, remember) =>
set(() => ({
appUserRemember: {
username,
password,
remember,
},
appPersistSession: remember,
appUserRemember: remember
? {
username,
password,
remember,
}
: null,
})),
resetStore: () => {
const rememberedUser = get().appUserRemember;
......@@ -164,27 +247,40 @@ const useAuthStore = create<AuthStoreStateType>()(
appUserRemember: rememberedUser,
_hasHydrated: true,
}));
try {
localStorage.removeItem("app-auth-storage");
} catch {
// ignore
}
},
}),
{
name: "app-auth-storage",
storage: createJSONStorage(() => ({
getItem: (name) => localStorage.getItem(name),
setItem: (name, value) => {
if (shouldPersistAuthStorage(value)) {
localStorage.setItem(name, value);
return;
}
localStorage.removeItem(name);
},
removeItem: (name) => localStorage.removeItem(name),
})),
partialize: (state) => ({
appIsLoggedIn: state.appIsLoggedIn,
appAccessToken: state.appAccessToken,
appAccessTokenExpired: state.appAccessTokenExpired,
appRefreshToken: state.appRefreshToken,
appSession: state.appSession,
appUser: state.appUser,
appSessionExpiredNotified: state.appSessionExpiredNotified,
appPersistSession: state.appPersistSession,
...(state.appPersistSession
? {
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: (state) => {
merge: (persistedState, currentState) =>
normalizePersistedAuthState(persistedState, currentState),
onRehydrateStorage: () => {
return (state: AuthStoreStateType | undefined, error: unknown) => {
if (error) {
useAuthStore.persist.clearStorage();
......
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