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

Merge branch 'fix/header' into 'develop-news'

super update UI

See merge request !60
parents 4b4dbccb 80e61988
...@@ -5,6 +5,7 @@ import { addMonths, format, getDay, startOfMonth, subMonths } from "date-fns"; ...@@ -5,6 +5,7 @@ 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";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
const weekDays = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"]; const weekDays = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
...@@ -17,7 +18,13 @@ const isTrainingEvent = (item: HomePostItem) => ...@@ -17,7 +18,13 @@ const isTrainingEvent = (item: HomePostItem) =>
return key.includes("đào tạo") || key.includes("dao-tao"); return key.includes("đào tạo") || key.includes("dao-tao");
}); });
function EventsCalendar() { function EventsCalendar({
className,
compact = false,
}: {
className?: string;
compact?: boolean;
}) {
const { eventCalendarPosts } = useHomePosts(); const { eventCalendarPosts } = useHomePosts();
const firstEventDate = eventCalendarPosts[0]?.registrationDeadline const firstEventDate = eventCalendarPosts[0]?.registrationDeadline
...@@ -71,18 +78,36 @@ function EventsCalendar() { ...@@ -71,18 +78,36 @@ function EventsCalendar() {
const highlightedEvent = selectedEvents[0] ?? monthEvents[0]; const highlightedEvent = selectedEvents[0] ?? monthEvents[0];
return ( 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]"> <aside
className={cn(
"w-full rounded-[28px] bg-white text-[#24469c] shadow-[0_18px_38px_rgba(16,61,130,0.16)]",
compact ? "p-4" : "p-4 md:p-5",
className ?? "xl:w-[28%] xl:min-w-[320px]",
)}
>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div className="min-w-0">
<h2 className="client-section-title uppercase"> <h2
className={cn(
"uppercase",
compact
? "text-[26px] font-bold leading-tight tracking-normal"
: "client-section-title",
)}
>
Lịch sự kiện Lịch sự kiện
</h2> </h2>
<p className="mt-1.5 text-[12px] uppercase tracking-[0.28em] text-[#7f8eab]"> <p
className={cn(
"mt-1.5 text-[12px] uppercase text-[#7f8eab]",
compact ? "tracking-[0.18em]" : "tracking-[0.28em]",
)}
>
{`THÁNG ${format(currentMonth, "MM/yyyy")}`} {`THÁNG ${format(currentMonth, "MM/yyyy")}`}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex shrink-0 gap-2">
<button <button
type="button" type="button"
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))} onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
...@@ -100,8 +125,8 @@ function EventsCalendar() { ...@@ -100,8 +125,8 @@ function EventsCalendar() {
</div> </div>
</div> </div>
<div className="mt-3 h-[4px] w-[60px] rounded-full bg-[#f7b500]" /> <div className={cn("h-[4px] w-[60px] rounded-full bg-[#f7b500]", compact ? "mt-2.5" : "mt-3")} />
<div className="mt-4 border-t border-[#ebf0f8] pt-3.5"> <div className={cn("border-t border-[#ebf0f8] pt-3.5", compact ? "mt-3" : "mt-4")}>
<div className="grid grid-cols-7 gap-y-2.5 text-center text-[11px] font-semibold uppercase text-[#9aabc6]"> <div className="grid grid-cols-7 gap-y-2.5 text-center text-[11px] font-semibold uppercase text-[#9aabc6]">
{weekDays.map((day) => ( {weekDays.map((day) => (
<div key={day}>{day}</div> <div key={day}>{day}</div>
...@@ -159,7 +184,7 @@ function EventsCalendar() { ...@@ -159,7 +184,7 @@ function EventsCalendar() {
</div> </div>
</div> </div>
<div className="mt-4 flex items-center gap-5 text-[12px] font-medium text-[#45608f]"> <div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12px] font-medium text-[#45608f]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-[#1e3f9a]" /> <span className="h-2.5 w-2.5 rounded-full bg-[#1e3f9a]" />
<span>Sự kiện</span> <span>Sự kiện</span>
......
...@@ -5,11 +5,17 @@ import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts"; ...@@ -5,11 +5,17 @@ import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import memberImages from "@/constants/memberImages"; import memberImages from "@/constants/memberImages";
import Link from "next/link"; import Link from "next/link";
const MEMBER_CONNECTION_FALLBACK_IMAGE = "/home/20-2048x1365.webp";
function Members() { function Members() {
const { memberConnectionPosts, categoryLinks, categoryNames } = useHomePosts(); const { memberConnectionPosts, categoryLinks, categoryNames } = useHomePosts();
const featuredConnection = memberConnectionPosts[0]; const featuredConnection = memberConnectionPosts[0];
const sectionLink = const sectionLink =
categoryLinks.get(categoryNames.ketNoiHoiVien.toLowerCase()) ?? "/hoi-vien/ket-noi-hoi-vien"; categoryLinks.get(categoryNames.ketNoiHoiVien.toLowerCase()) ?? "/hoi-vien/ket-noi-hoi-vien";
const connectionImage =
featuredConnection?.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE;
const connectionImageAlt =
featuredConnection?.thumbnail?.alt || featuredConnection?.title || "VCCI HCM";
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">
...@@ -62,26 +68,20 @@ function Members() { ...@@ -62,26 +68,20 @@ function Members() {
</div> </div>
</div> </div>
{featuredConnection ? ( <Link
<Link href={sectionLink}
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]"> <ImageNext
<ImageNext src={connectionImage}
src={featuredConnection.thumbnail?.url ?? "/thumbnail.png"} alt={connectionImageAlt}
alt={featuredConnection.thumbnail?.alt || featuredConnection.title} width={520}
width={520} height={420}
height={420} className="h-full w-full object-cover"
className="h-full w-full object-cover" />
/>
</div>
</Link>
) : (
<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> </div>
)} </Link>
</aside> </aside>
</section> </section>
); );
......
...@@ -22,7 +22,7 @@ const videos = [ ...@@ -22,7 +22,7 @@ const videos = [
function VideoAndPartners() { function VideoAndPartners() {
return ( return (
<section className="flex flex-col gap-6 pb-10 xl:flex-row xl:items-start"> <section className="flex flex-col gap-6 pb-10 xl:flex-row xl:items-stretch">
<div className="flex-1"> <div className="flex-1">
<div className="mb-5 flex items-start justify-between gap-3"> <div className="mb-5 flex items-start justify-between gap-3">
<div> <div>
...@@ -75,7 +75,7 @@ function VideoAndPartners() { ...@@ -75,7 +75,7 @@ function VideoAndPartners() {
</div> </div>
</div> </div>
<aside className="w-full xl:w-[43%]"> <aside className="flex w-full flex-col xl:w-[43%]">
<div className="mb-5 flex items-start justify-between gap-3"> <div className="mb-5 flex items-start justify-between gap-3">
<div> <div>
<h2 className="client-section-title uppercase text-[#24469c]"> <h2 className="client-section-title uppercase text-[#24469c]">
...@@ -85,11 +85,11 @@ function VideoAndPartners() { ...@@ -85,11 +85,11 @@ function VideoAndPartners() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 xl:h-[318px] xl:grid-rows-2">
{partnerImages.slice(0, 6).map((src, index) => ( {partnerImages.slice(0, 6).map((src, index) => (
<div <div
key={src} key={src}
className="flex h-[96px] items-center justify-center rounded-[14px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)]" className="flex h-[96px] items-center justify-center rounded-[14px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] xl:h-auto"
> >
<ImageNext <ImageNext
src={src} src={src}
......
...@@ -29,7 +29,7 @@ const Page = () => { ...@@ -29,7 +29,7 @@ const Page = () => {
</Link> </Link>
</div> */} </div> */}
<section className="flex flex-col lg:flex-row pb-16 gap-5 mb-0"> <section className="flex flex-col lg:flex-row pb-8 gap-5 mb-0">
<News /> <News />
<QuickLinks /> <QuickLinks />
</section > </section >
......
...@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query"; ...@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import ArticlePage from "./templates/ArticlePage"; import ArticlePage from "./templates/ArticlePage";
import ArticleDetailPage from "./templates/ArticleDetailPage"; import ArticleDetailPage from "./templates/ArticleDetailPage";
import CatalogPage from "./templates/CatalogPage";
import InformationPage from "./templates/InformationPage"; import InformationPage from "./templates/InformationPage";
import { import {
fetchDynamicCategories, fetchDynamicCategories,
...@@ -98,6 +99,18 @@ export default function DynamicPage() { ...@@ -98,6 +99,18 @@ export default function DynamicPage() {
} }
if (resolvedCategory?.type === "news") { if (resolvedCategory?.type === "news") {
if (
resolvedCategory.slug === "an-pham" ||
resolvedCategory.slug === "thu-vien-tai-lieu"
) {
return (
<CatalogPage
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
return ( return (
<ArticlePage <ArticlePage
category={resolvedCategory} category={resolvedCategory}
......
...@@ -2,12 +2,9 @@ ...@@ -2,12 +2,9 @@
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 ImageNext from "@/components/shared/image-next";
import ListCategory from "@/components/base/list-category"; import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import { import { getDynamicPostBodyHtml } from "./data";
buildDynamicCategoryMenu,
getDynamicPostBodyHtml,
} from "./data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types"; import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
type ArticleDetailPageProps = { type ArticleDetailPageProps = {
...@@ -19,35 +16,128 @@ type ArticleDetailPageProps = { ...@@ -19,35 +16,128 @@ type ArticleDetailPageProps = {
export default function ArticleDetailPage({ export default function ArticleDetailPage({
post, post,
category, category,
allCategories,
}: ArticleDetailPageProps) { }: ArticleDetailPageProps) {
const categoryMenu = category const publishedDate = dayjs(
? buildDynamicCategoryMenu(category, allCategories) post.release_at ?? post.published_at ?? post.created_at,
: []; ).format("DD/MM/YYYY");
const primaryCategory = post.categories[0]?.name || category?.name || "Tin tức";
return ( return (
<div className="container w-full flex justify-center items-center pb-10"> <div className="min-h-screen bg-[#fbfbfa]">
<div className="flex flex-col gap-5 w-full"> <div className="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10">
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />} <div className="grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_340px] xl:gap-12">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <main className="min-w-0">
<main className="lg:col-span-2 bg-white border rounded-md p-8"> <div className="mb-5 flex flex-wrap items-center gap-3 text-xs">
<div className="pb-5 text-primary text-2xl leading-normal font-medium"> <span className="rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]">
{post.title} {primaryCategory}
</div>
<div className="flex items-center gap-2 text-sm mb-4">
<span className="text-base text-blue-700">
{dayjs(post.release_at ?? post.published_at ?? post.created_at).format("DD/MM/YYYY")}
</span> </span>
<span className="text-[#9aa3ad]">{publishedDate}</span>
</div> </div>
<hr className="my-5" />
<div className="flex-1 text-app-grey text-base overflow-hidden"> <h1 className="max-w-4xl text-3xl font-bold leading-tight text-[#111827] md:text-[38px] md:leading-[1.15]">
<div className="prose tiptap max-w-none overflow-hidden"> {post.title}
</h1>
<div className="mt-3 h-[3px] w-16 rounded-full bg-[#f5a400]" />
{post.summary ? (
<p className="mt-5 max-w-4xl text-base font-semibold leading-7 text-[#374151] md:text-lg md:leading-8">
{post.summary}
</p>
) : null}
<div className="mt-7 rounded-[24px] bg-white px-4 py-5 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 sm:py-6 lg:px-10">
<div className="article-detail-content prose tiptap max-w-none overflow-hidden">
{parse(getDynamicPostBodyHtml(post))} {parse(getDynamicPostBodyHtml(post))}
</div> </div>
</div> </div>
<div className="article-detail-styles">
<style jsx global>{`
.article-detail-content {
color: #1f2937;
font-size: 16px;
line-height: 1.85;
}
.article-detail-content p,
.article-detail-content div {
margin: 0 0 18px;
}
.article-detail-content h1,
.article-detail-content h2,
.article-detail-content h3,
.article-detail-content h4,
.article-detail-content h5,
.article-detail-content h6 {
margin: 0 0 18px;
color: #111827;
font-weight: 700;
line-height: 1.45;
}
.article-detail-content img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
margin: 24px auto 10px;
border-radius: 14px;
}
.article-detail-content figure {
margin: 28px 0;
}
.article-detail-content figcaption,
.article-detail-content .wp-caption-text {
margin-top: 10px;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
text-align: center;
}
.article-detail-content a {
color: #14519f;
font-weight: 600;
}
.article-detail-content ul,
.article-detail-content ol {
margin: 18px 0;
padding-left: 24px;
}
.article-detail-content li {
margin: 8px 0;
}
`}</style>
</div>
</main> </main>
<aside className="space-y-6">
<EventCalendar /> <aside className="space-y-5">
<EventsCalendar compact className="xl:w-full xl:min-w-0" />
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]">
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên
</div>
</div>
</div>
</div>
</aside> </aside>
</div> </div>
</div> </div>
......
'use client'; 'use client';
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination"; import { Pagination } from "@/components/base/pagination";
import ListFilter from "@/components/base/list-filter"; import ImageNext from "@/components/shared/image-next";
import EventCalendar from "@/components/base/event-calendar"; import { Button } from "@/components/ui/button";
import ListCategory from "@/components/base/list-category"; import { Input } from "@/components/ui/input";
import { import {
buildDynamicCategoryMenu,
buildPostFilters, buildPostFilters,
fetchDynamicPostList, fetchDynamicPostList,
resolveDynamicPostImage,
stripHtml, stripHtml,
} from "./data"; } from "./data";
import type { DynamicCategoryRouteItem } from "./types"; import type { DynamicCategoryRouteItem } from "./types";
import CardNews from "@/components/base/card-news";
type ArticlePageProps = { type ArticlePageProps = {
category: DynamicCategoryRouteItem; category: DynamicCategoryRouteItem;
allCategories: DynamicCategoryRouteItem[]; allCategories: DynamicCategoryRouteItem[];
}; };
const formatPostDate = (value?: string | null) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
};
const getTagClassName = (index: number) => {
const classes = [
"bg-[#eaf0ff] text-[#1f4fa3]",
"bg-[#e9f7ee] text-[#138040]",
"bg-[#fff0e3] text-[#d47a16]",
"bg-[#ffe9f0] text-[#d22f62]",
];
return classes[index % classes.length];
};
export default function ArticlePage({ category, allCategories }: ArticlePageProps) { export default function ArticlePage({ category, allCategories }: ArticlePageProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
...@@ -29,9 +53,10 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -29,9 +53,10 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
const searchParamsString = searchParams.toString(); const searchParamsString = searchParams.toString();
const initialPage = Number(searchParams.get("page") ?? "1"); const initialPage = Number(searchParams.get("page") ?? "1");
const [searchInput, setSearchInput] = useState("");
const [submitSearch, setSubmitSearch] = useState(""); const [submitSearch, setSubmitSearch] = useState("");
const [page, setPage] = useState(initialPage); const [page, setPage] = useState(initialPage);
const pageSize = 6; const pageSize = 10;
const keyword = submitSearch.trim(); const keyword = submitSearch.trim();
useEffect(() => { useEffect(() => {
...@@ -51,10 +76,6 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -51,10 +76,6 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
} }
}, [page, pathname, router, searchParamsString]); }, [page, pathname, router, searchParamsString]);
useEffect(() => {
setPage(1);
}, [submitSearch, category.id]);
const postsQuery = useQuery({ const postsQuery = useQuery({
queryKey: ["dynamic-posts", category.id, page, pageSize, keyword], queryKey: ["dynamic-posts", category.id, page, pageSize, keyword],
queryFn: () => queryFn: () =>
...@@ -73,78 +94,98 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -73,78 +94,98 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
const categoryMenu = useMemo(
() => buildDynamicCategoryMenu(category, allCategories),
[category, allCategories],
);
const totalPages = postsQuery.data?.totalPages ?? 1; const totalPages = postsQuery.data?.totalPages ?? 1;
const currentPage = Math.min(page, totalPages); const currentPage = Math.min(page, totalPages);
const paginatedPosts = postsQuery.data?.rows ?? []; const paginatedPosts = postsQuery.data?.rows ?? [];
const categoryIndexMap = useMemo(() => {
const entries = allCategories.map((item, index) => [item.id, index] as const);
return new Map(entries);
}, [allCategories]);
return ( return (
<div className="min-h-screen container mx-auto"> <div className="min-h-screen bg-[#fbfbfa]">
{postsQuery.isLoading ? ( {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="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10">
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />} <div className="mb-8">
<h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
{category.name}
</h1>
<div className="mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="flex flex-col gap-10 xl:flex-row xl:gap-14">
<main className="lg:col-span-2 bg-white"> <main className="order-2 min-w-0 xl:order-1 xl:flex-1">
<div className="pb-5 overflow-hidden"> <div className="space-y-9">
{paginatedPosts.length ? ( {paginatedPosts.length ? (
paginatedPosts.map((item) => { paginatedPosts.map((item, index) => {
const fallbackDescription = item.content_structure?.post_content const fallbackDescription = item.content_structure?.post_content
?.map((section) => section.content) ?.map((section) => section.content)
.join(" "); .join(" ");
const description =
item.summary ||
stripHtml(item.content) ||
stripHtml(fallbackDescription);
const primaryCategory = item.categories[0];
const tagIndex = categoryIndexMap.get(primaryCategory?.id ?? "") ?? index;
const date = formatPostDate(
item.release_at ?? item.published_at ?? item.created_at,
);
return ( return (
<CardNews <article
key={item.id} key={item.id}
news={{ className="border-b border-[#eceff3] pb-8 last:border-b-0"
id: item.id, >
title: item.title, <Link
thumbnail: href={item.external_link}
item.thumbnail?.path ?? className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
item.thumbnail?.original ?? >
item.thumbnail?.url ?? <div className="overflow-hidden rounded-md bg-[#edf1f5]">
"", <ImageNext
external_link: item.external_link, src={resolveDynamicPostImage(item.thumbnail)}
description: alt={item.title}
item.summary || width={520}
stripHtml(item.content) || height={360}
stripHtml(fallbackDescription), className="h-[170px] w-full object-cover transition-transform duration-500 group-hover:scale-[1.03] sm:h-[150px]"
release_at: />
item.release_at ?? item.published_at ?? item.created_at ?? "", </div>
is_active: item.is_active,
created_at: item.created_at ?? "", <div className="min-w-0 pt-1">
created_by: null, <div className="flex flex-wrap items-center gap-3 text-xs">
updated_at: item.created_at ?? "", <span
updated_by: null, className={`rounded-full px-2.5 py-1 font-semibold ${getTagClassName(tagIndex)}`}
mode: "NOW", >
category: category.name, {primaryCategory?.name || category.name}
page_config: { </span>
id: category.id, {date ? <span className="text-[#9aa3ad]">{date}</span> : null}
name: category.name, </div>
static_link: category.url,
static_link_en: category.url, <h2 className="mt-3 line-clamp-2 text-[18px] font-bold leading-snug text-[#111827] transition-colors group-hover:text-[#144c9c]">
code: category.slug, {item.title}
}, </h2>
}}
link={item.external_link} {description ? (
/> <p className="mt-2 line-clamp-3 text-sm leading-6 text-[#5f6875]">
{description}
</p>
) : null}
</div>
</Link>
</article>
); );
}) })
) : ( ) : (
<div className="rounded-lg border bg-white px-6 py-12 text-center text-gray-600"> <div className="rounded-2xl border border-[#edf1f5] 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."} {"Ch\u01b0a c\u00f3 b\u00e0i vi\u1ebft ph\u00f9 h\u1ee3p trong danh m\u1ee5c n\u00e0y."}
</div> </div>
)} )}
<div className="w-full flex justify-center mt-4"> <div className="flex w-full justify-center pt-2">
<Pagination <Pagination
pageCount={totalPages} pageCount={totalPages}
page={currentPage} page={currentPage}
...@@ -156,12 +197,62 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -156,12 +197,62 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
</div> </div>
</main> </main>
<aside className="space-y-6"> <aside className="order-1 space-y-5 xl:order-2 xl:w-[320px] xl:pt-0">
<ListFilter onSearch={setSubmitSearch} onReset={() => setSubmitSearch("")} /> <form
<EventCalendar /> className="rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]"
<div className="bg-white border rounded-md overflow-hidden"> onSubmit={(event) => {
<div className="w-full relative bg-gray-100"> event.preventDefault();
<img src="/banner.webp" alt={"Qu\u1ea3ng c\u00e1o"} className="object-cover" /> setPage(1);
setSubmitSearch(searchInput);
}}
>
<h2 className="text-lg font-bold text-[#111827]">Tìm kiếm</h2>
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Tên bài viết ..."
className="mt-4 h-11 rounded-xl border-[#edf1f5] bg-[#f8fafc] text-sm placeholder:text-gray-700"
/>
<div className="mt-4 grid grid-cols-2 gap-3">
<Button
type="submit"
className="h-11 rounded-xl bg-[#14519f] text-white hover:bg-[#0f4386]"
>
Tìm kiếm
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick={() => {
setSearchInput("");
setPage(1);
setSubmitSearch("");
}}
>
Bỏ tìm
</Button>
</div>
</form>
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]">
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên
</div>
</div>
</div> </div>
</div> </div>
</aside> </aside>
......
'use client';
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import ImageNext from "@/components/shared/image-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
buildPostFilters,
fetchDynamicPostList,
resolveDynamicPostImage,
} from "./data";
import type { DynamicCategoryRouteItem } from "./types";
type CatalogPageProps = {
category: DynamicCategoryRouteItem;
allCategories: DynamicCategoryRouteItem[];
};
const getCatalogImageClassName = (index: number) =>
index % 4 === 0
? "object-cover"
: index % 4 === 1
? "object-contain bg-[#f0f3f8]"
: index % 4 === 2
? "object-cover object-center"
: "object-contain bg-[#eef4fb]";
export default function CatalogPage({ category, allCategories }: CatalogPageProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParamsString = searchParams.toString();
const initialPage = Number(searchParams.get("page") ?? "1");
const [searchInput, setSearchInput] = useState("");
const [submitSearch, setSubmitSearch] = useState("");
const [page, setPage] = useState(initialPage);
const pageSize = 8;
const keyword = submitSearch.trim();
useEffect(() => {
const params = new URLSearchParams(searchParamsString);
if (page > 1) {
params.set("page", String(page));
} else {
params.delete("page");
}
const qs = params.toString();
const nextUrl = qs ? `${pathname}?${qs}` : pathname;
const currentUrl = searchParamsString ? `${pathname}?${searchParamsString}` : pathname;
if (nextUrl !== currentUrl) {
router.replace(nextUrl, { scroll: false });
}
}, [page, pathname, router, searchParamsString]);
const postsQuery = useQuery({
queryKey: ["catalog-posts", category.id, page, pageSize, keyword],
queryFn: () =>
fetchDynamicPostList({
page,
pageSize,
filters: buildPostFilters([
`category.id==${category.id}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
keyword ? `title@=${keyword}` : null,
]),
}),
staleTime: 60 * 1000,
});
const totalPages = postsQuery.data?.totalPages ?? 1;
const currentPage = Math.min(page, totalPages);
const paginatedPosts = postsQuery.data?.rows ?? [];
return (
<div className="min-h-screen bg-[#fbfbfa]">
{postsQuery.isLoading ? (
<div className="flex h-64 w-full items-center justify-center">
<Spinner />
</div>
) : (
<div className="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10">
<div className="mb-8">
<h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
{category.name}
</h1>
<div className="mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]" />
</div>
<div className="flex flex-col gap-10 xl:flex-row xl:gap-14">
<main className="order-2 min-w-0 xl:order-1 xl:flex-1">
{paginatedPosts.length ? (
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4 xl:gap-6">
{paginatedPosts.map((item, index) => {
return (
<Link
key={item.id}
href={item.external_link}
className="group block"
>
<div className="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]">
<div className="relative aspect-[3/4] overflow-hidden bg-white">
<ImageNext
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
height={693}
className={`h-full w-full transition-transform duration-500 group-hover:scale-[1.03] ${getCatalogImageClassName(index)}`}
/>
</div>
</div>
<div className="px-1 pt-3 text-center">
<h2 className="line-clamp-2 text-[14px] leading-[1.45] text-[#1f2f57]">
{item.title}
</h2>
</div>
</Link>
);
})}
</div>
) : (
<div className="rounded-2xl border border-[#edf1f5] 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="flex w-full justify-center pt-8">
<Pagination
pageCount={totalPages}
page={currentPage}
onChangePage={setPage}
onGoToPreviousPage={() => setPage(Math.max(1, currentPage - 1))}
onGoToNextPage={() => setPage(Math.min(totalPages, currentPage + 1))}
/>
</div>
</main>
<aside className="order-1 space-y-5 xl:order-2 xl:w-[320px] xl:pt-0">
<form
className="rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]"
onSubmit={(event) => {
event.preventDefault();
setPage(1);
setSubmitSearch(searchInput);
}}
>
<h2 className="text-lg font-bold text-[#111827]">Tìm kiếm</h2>
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Tên bài viết ..."
className="mt-4 h-11 rounded-xl border-[#edf1f5] bg-[#f8fafc] text-sm placeholder:text-gray-700"
/>
<div className="mt-4 grid grid-cols-2 gap-3">
<Button
type="submit"
className="h-11 rounded-xl bg-[#14519f] text-white hover:bg-[#0f4386]"
>
Tìm kiếm
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick={() => {
setSearchInput("");
setPage(1);
setSubmitSearch("");
}}
>
Bỏ tìm
</Button>
</div>
</form>
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]">
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên
</div>
</div>
</div>
</div>
</aside>
</div>
</div>
)}
</div>
);
}
...@@ -37,24 +37,24 @@ export default function EventDetailPage() { ...@@ -37,24 +37,24 @@ export default function EventDetailPage() {
return notFound(); return notFound();
} }
return ( return (
<div className='container w-full flex justify-center items-center pb-10'> <div className='container flex w-full items-center justify-center px-4 pb-10 sm:px-6 lg:px-10'>
{isLoading ? ( {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='flex flex-col gap-5 w-full'> <div className='flex w-full flex-col gap-5'>
<ListCategory categories={category?.responseData?.children} /> <ListCategory categories={category?.responseData?.children} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_340px]">
<main className="lg:col-span-2 bg-white border rounded-md py-10 px-5 md:px-20"> <main className="min-w-0 rounded-md border bg-white px-4 py-6 sm:px-6 md:px-10 lg:px-14 lg:py-10">
<div className='pb-5 text-primary text-2xl leading-normal font-medium'> <div className='pb-5 text-primary text-2xl leading-normal font-medium'>
{eventsDetail?.responseData?.rows[0]?.name} {eventsDetail?.responseData?.rows[0]?.name}
</div> </div>
<hr className="py-2" /> <hr className="py-2" />
{/* Top summary with image + details */} {/* Top summary with image + details */}
<div className="flex flex-col md:flex-row gap-6 my-6"> <div className="my-6 flex flex-col gap-6 md:flex-row">
<div className="w-full lg:w-1/2 bg-gray-50 rounded-md overflow-hidden"> <div className="w-full overflow-hidden rounded-md bg-gray-50 md:w-1/2">
{eventsDetail?.responseData?.rows[0].image ? ( {eventsDetail?.responseData?.rows[0].image ? (
<div className="w-full h-52 relative "> <div className="w-full h-52 relative ">
<EventImage <EventImage
...@@ -67,7 +67,7 @@ export default function EventDetailPage() { ...@@ -67,7 +67,7 @@ export default function EventDetailPage() {
)} )}
</div> </div>
<div className="w-full lg:w-1/2 bg-white border rounded-md p-3 md:p-6"> <div className="w-full rounded-md border bg-white p-3 md:w-1/2 md:p-6">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="text-sm text-gray-500 flex flex-row items-center gap-2"> <div className="text-sm text-gray-500 flex flex-row items-center gap-2">
<Clock className="h-5 w-5 text-yellow-500" /> <Clock className="h-5 w-5 text-yellow-500" />
...@@ -112,13 +112,13 @@ export default function EventDetailPage() { ...@@ -112,13 +112,13 @@ export default function EventDetailPage() {
</div> </div>
{/* Full description */} {/* Full description */}
<div className="prose tiptap overflow-hidden"> <div className="prose tiptap max-w-none overflow-hidden">
{parse(eventsDetail?.responseData?.rows[0]?.description ?? "")} {parse(eventsDetail?.responseData?.rows[0]?.description ?? "")}
</div> </div>
</main> </main>
{/* Sidebar */} {/* Sidebar */}
<aside className="space-y-6"> <aside className="min-w-0 space-y-6">
<EventCalendar /> <EventCalendar />
<div className="bg-white border rounded-md overflow-hidden"> <div className="bg-white border rounded-md overflow-hidden">
<div className="w-full h-75 relative bg-gray-100"> <div className="w-full h-75 relative bg-gray-100">
...@@ -159,4 +159,4 @@ function EventImage({ src, alt }: EventImageProps) { ...@@ -159,4 +159,4 @@ function EventImage({ src, alt }: EventImageProps) {
}} }}
/> />
); );
} }
\ No newline at end of file
...@@ -61,17 +61,17 @@ export default function EventPage() { ...@@ -61,17 +61,17 @@ export default function EventPage() {
//template //template
return ( return (
<> <>
<div className="min-h-screen container mx-auto"> <div className="container mx-auto min-h-screen px-4 py-6 sm:px-6 lg:px-10">
{eventsLoading ? ( {eventsLoading ? (
<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="flex w-full flex-col gap-5">
<ListCategory categories={categoriesPage?.responseData?.children} /> <ListCategory categories={categoriesPage?.responseData?.children} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<main className="lg:col-span-2 bg-background"> <main className="min-w-0 bg-background">
<div className="pb-5 overflow-hidden"> <div className="overflow-hidden pb-5">
{events?.responseData?.rows?.map((item) => ( {events?.responseData?.rows?.map((item) => (
<CardEvents <CardEvents
key={item.id} key={item.id}
...@@ -92,7 +92,7 @@ export default function EventPage() { ...@@ -92,7 +92,7 @@ export default function EventPage() {
</div> </div>
</div> </div>
</main> </main>
<aside className="space-y-6"> <aside className="min-w-0 space-y-6">
<ListFilter onSearch={setSubmitSearch} /> <ListFilter onSearch={setSubmitSearch} />
<EventCalendar /> <EventCalendar />
</aside> </aside>
......
'use client'; 'use client';
import dayjs from "dayjs";
import parse from "html-react-parser"; import parse from "html-react-parser";
import ListCategory from "@/components/base/list-category"; import { getDynamicPostBodyHtml } from "./data";
import {
buildDynamicCategoryMenu,
getDynamicPostBodyHtml,
} from "./data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types"; import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
type InformationPageProps = { type InformationPageProps = {
...@@ -17,24 +14,102 @@ type InformationPageProps = { ...@@ -17,24 +14,102 @@ type InformationPageProps = {
export default function InformationPage({ export default function InformationPage({
post, post,
category, category,
allCategories,
}: InformationPageProps) { }: InformationPageProps) {
const categoryMenu = buildDynamicCategoryMenu(category, allCategories); const publishedDate = dayjs(
post.release_at ?? post.published_at ?? post.created_at,
).format("DD/MM/YYYY");
return ( return (
<div className="container w-full flex justify-center items-center pb-10"> <div className="min-h-screen bg-[#fbfbfa]">
<div className="flex flex-col gap-5 w-full"> <div className="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10">
{categoryMenu.length > 0 ? <ListCategory categories={categoryMenu} /> : <br />} <main className="w-full">
<main className="bg-white border rounded-md py-10 px-5 md:px-20 lg:px-20"> <div className="mb-5 flex flex-wrap items-center gap-3 text-xs">
<div className="text-primary text-2xl leading-normal font-bold"> <span className="rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]">
{post.title} {category.name}
</span>
<span className="text-[#9aa3ad]">{publishedDate}</span>
</div> </div>
<hr className="my-5" />
<div className="flex-1 text-app-grey text-base overflow-hidden"> <h1 className="max-w-6xl text-3xl font-bold leading-tight text-[#111827] md:text-[38px] md:leading-[1.15]">
<div className="prose tiptap max-w-none overflow-hidden"> {post.title}
</h1>
<div className="mt-3 h-[3px] w-16 rounded-full bg-[#f5a400]" />
{post.summary ? (
<p className="mt-5 max-w-6xl text-base font-semibold leading-7 text-[#374151] md:text-lg md:leading-8">
{post.summary}
</p>
) : null}
<div className="mt-7 rounded-[24px] bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10">
<div className="page-detail-content prose tiptap max-w-none overflow-hidden">
{parse(getDynamicPostBodyHtml(post))} {parse(getDynamicPostBodyHtml(post))}
</div> </div>
</div> </div>
<div className="page-detail-styles">
<style jsx global>{`
.page-detail-content {
color: #1f2937;
font-size: 16px;
line-height: 1.85;
}
.page-detail-content p,
.page-detail-content div {
margin: 0 0 18px;
}
.page-detail-content h1,
.page-detail-content h2,
.page-detail-content h3,
.page-detail-content h4,
.page-detail-content h5,
.page-detail-content h6 {
margin: 0 0 18px;
color: #111827;
font-weight: 700;
line-height: 1.45;
}
.page-detail-content img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
margin: 24px auto 10px;
border-radius: 14px;
}
.page-detail-content figure {
margin: 28px 0;
}
.page-detail-content figcaption,
.page-detail-content .wp-caption-text {
margin-top: 10px;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
text-align: center;
}
.page-detail-content a {
color: #14519f;
font-weight: 600;
}
.page-detail-content ul,
.page-detail-content ol {
margin: 18px 0;
padding-left: 24px;
}
.page-detail-content li {
margin: 8px 0;
}
`}</style>
</div>
</main> </main>
</div> </div>
</div> </div>
......
...@@ -143,12 +143,12 @@ function Header() { ...@@ -143,12 +143,12 @@ function Header() {
{"\u0110\u0103ng K\u00fd H\u1ed9i Vi\u00ean"} {"\u0110\u0103ng K\u00fd H\u1ed9i Vi\u00ean"}
</Link> </Link>
</div> </div>
<Link {/* <Link
className="px-3 py-1 text-[13px] font-medium text-white transition hover:opacity-80" className="px-3 py-1 text-[13px] font-medium text-white transition hover:opacity-80"
href="/site-map" href="/site-map"
> >
Sitemap Sitemap
</Link> </Link> */}
<Link <Link
className="px-3 py-1 text-[13px] font-medium text-white transition hover:opacity-80" className="px-3 py-1 text-[13px] font-medium text-white transition hover:opacity-80"
href="https://vccihcm.vn/lien-he" href="https://vccihcm.vn/lien-he"
...@@ -252,13 +252,15 @@ function Header() { ...@@ -252,13 +252,15 @@ function Header() {
</div> </div>
<div <div
className={`overflow-hidden border-t border-slate-200 bg-white transition-all duration-300 lg:hidden ${ className={`fixed left-0 right-0 top-[80px] z-40 border-t border-slate-200 bg-white transition-all duration-300 lg:hidden ${
toggleMenu ? "max-h-[520px] opacity-100" : "max-h-0 opacity-0" toggleMenu
? "pointer-events-auto h-[calc(100dvh-80px)] translate-y-0 opacity-100"
: "pointer-events-none h-[calc(100dvh-80px)] -translate-y-2 opacity-0"
}`} }`}
> >
<div className="px-4 py-3"> <div className="flex h-full flex-col overflow-y-auto overscroll-contain px-4 py-3">
<input <input
className="h-11 w-full rounded-md border border-slate-200 px-4 text-sm outline-none placeholder:text-slate-400 focus:border-[#2f57ff]" className="h-11 w-full shrink-0 rounded-md border border-slate-200 px-4 text-sm outline-none placeholder:text-slate-400 focus:border-[#2f57ff]"
type="text" type="text"
placeholder={"T\u00ecm ki\u1ebfm"} placeholder={"T\u00ecm ki\u1ebfm"}
onKeyDown={(e) => { onKeyDown={(e) => {
...@@ -270,34 +272,34 @@ function Header() { ...@@ -270,34 +272,34 @@ function Header() {
} }
}} }}
/> />
</div>
<div className="pb-3"> <div className="pb-6">
{menuItems.map((category) => ( {menuItems.map((category) => (
<div key={category.id} className="border-t border-slate-100 first:border-t-0"> <div key={category.id} className="border-t border-slate-100 first:border-t-0">
<Link <Link
href={category.url || "#"} href={category.url || "#"}
className="block px-5 py-3 text-[15px] font-medium text-slate-700 transition hover:bg-slate-50 hover:text-[#2f57ff]" className="block px-5 py-3 text-[15px] font-medium text-slate-700 transition hover:bg-slate-50 hover:text-[#2f57ff]"
onClick={() => setToggleMenu(false)} onClick={() => setToggleMenu(false)}
> >
{category.name} {category.name}
</Link> </Link>
{category.children.length > 0 ? ( {category.children.length > 0 ? (
<div className="pb-2 pl-8 pr-5"> <div className="pb-2 pl-8 pr-5">
{category.children.map((child) => ( {category.children.map((child) => (
<Link <Link
key={child.id} key={child.id}
href={child.url || "#"} href={child.url || "#"}
className="block py-2 text-sm text-slate-500 transition hover:text-[#2f57ff]" className="block py-2 text-sm text-slate-500 transition hover:text-[#2f57ff]"
onClick={() => setToggleMenu(false)} onClick={() => setToggleMenu(false)}
> >
{child.name} {child.name}
</Link> </Link>
))} ))}
</div> </div>
) : null} ) : null}
</div> </div>
))} ))}
</div>
</div> </div>
</div> </div>
</header> </header>
......
"use client"; "use client";
import React, { useState, Suspense, useEffect } from "react"; import { Suspense, useEffect, useState } from "react";
import ListCategory from "@/components/base/list-category"; import Link from "next/link";
import ListFilter from "@/components/base/list-filter"; import { useRouter, useSearchParams } from "next/navigation";
import CardNews from "@/components/base/card-news"; import { useQuery } from "@tanstack/react-query";
import ImageNext from "@/components/shared/image-next";
import { Pagination } from "@components/base/pagination"; import { Pagination } from "@components/base/pagination";
import Image from "next/image"; import { Button } from "@/components/ui/button";
import { useGetNews } from "@api/endpoints/news"; import { Input } from "@/components/ui/input";
import { GetNewsResponseType } from "@api/types/news";
import { Spinner } from "@components/ui/spinner"; import { Spinner } from "@components/ui/spinner";
import { useSearchParams, useRouter } from 'next/navigation' import {
buildPostFilters,
fetchDynamicPostList,
resolveDynamicPostImage,
stripHtml,
} from "@/app/(main)/[...slug]/templates/data";
import type { DynamicPostItem } from "@/app/(main)/[...slug]/templates/types";
const formatPostDate = (value?: string | null) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
};
const getTagClassName = (index: number) => {
const classes = [
"bg-[#eaf0ff] text-[#1f4fa3]",
"bg-[#e9f7ee] text-[#138040]",
"bg-[#fff0e3] text-[#d47a16]",
"bg-[#ffe9f0] text-[#d22f62]",
];
return classes[index % classes.length];
};
function SearchResultItem({ item, index }: { item: DynamicPostItem; index: number }) {
const fallbackDescription = item.content_structure?.post_content
?.map((section) => section.content)
.join(" ");
const description =
item.summary || stripHtml(item.content) || stripHtml(fallbackDescription);
const date = formatPostDate(item.release_at || item.published_at || item.created_at);
const categoryName = item.categories[0]?.name || "Tin tức";
return (
<article className="border-b border-[#eceff3] pb-8 last:border-b-0">
<Link
href={item.external_link || "#"}
className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="overflow-hidden rounded-md bg-[#edf1f5]">
<ImageNext
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
height={360}
className="h-[170px] w-full object-cover transition-transform duration-500 group-hover:scale-[1.03] sm:h-[150px]"
/>
</div>
<div className="min-w-0 pt-1">
<div className="flex flex-wrap items-center gap-3 text-xs">
<span className={`rounded-full px-2.5 py-1 font-semibold ${getTagClassName(index)}`}>
{categoryName}
</span>
{date ? <span className="text-[#9aa3ad]">{date}</span> : null}
</div>
<h2 className="mt-3 line-clamp-2 text-[18px] font-bold leading-snug text-[#111827] transition-colors group-hover:text-[#144c9c]">
{item.title}
</h2>
{description ? (
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[#5f6875]">
{description}
</p>
) : null}
</div>
</Link>
</article>
);
}
function SearchContent() { function SearchContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const query = searchParams.get('q') || ''; const query = searchParams.get("q") || "";
const pageFromUrl = searchParams.get('page'); const pageFromUrl = searchParams.get("page");
const [page, setPage] = useState(pageFromUrl ? parseInt(pageFromUrl) : 1); const [page, setPage] = useState(pageFromUrl ? Number(pageFromUrl) : 1);
const [searchInput, setSearchInput] = useState(query);
const pageSize = 5;
const { data: allData, isLoading } = useGetNews<GetNewsResponseType>({
pageSize: String(pageSize),
currentPage: String(page),
filters: query ? `title @=${query}` : undefined,
});
// Update URL when page changes const pageSize = 10;
useEffect(() => { const postsQuery = useQuery({
const params = new URLSearchParams(searchParams.toString()); queryKey: ["search-posts", page, pageSize, query],
params.set('page', String(page)); queryFn: () =>
router.push(`/search?${params.toString()}`, { scroll: false }); fetchDynamicPostList({
}, [page]); page,
pageSize,
filters: buildPostFilters([
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
query ? `title@=${query}` : null,
]),
}),
staleTime: 60 * 1000,
});
// Sync state with URL on mount/change
useEffect(() => { useEffect(() => {
if (pageFromUrl) { const nextPage = pageFromUrl ? Number(pageFromUrl) : 1;
setPage(parseInt(pageFromUrl)); if (Number.isFinite(nextPage)) {
setPage(nextPage);
} }
}, [pageFromUrl]); }, [pageFromUrl]);
useEffect(() => {
setSearchInput(query);
}, [query]);
const updateSearchUrl = (nextQuery: string, nextPage = 1) => {
const params = new URLSearchParams();
const trimmedQuery = nextQuery.trim();
if (trimmedQuery) params.set("q", trimmedQuery);
params.set("page", String(nextPage));
router.push(`/search?${params.toString()}`, { scroll: false });
};
const rows = postsQuery.data?.rows ?? [];
const totalPages = Number(postsQuery.data?.totalPages ?? 1);
const currentPage = Number(postsQuery.data?.page ?? page);
return ( return (
<div className="min-h-screen container mx-auto p-4"> <div className="min-h-screen bg-[#fbfbfa]">
<div className="w-full flex flex-col gap-5"> <div className="container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10">
<div className="border-t border-gray-200 bg-white p-2.5"> <div className="mb-8">
<div className="w-full px-4 sm:px-6 lg:px-8"> <h1 className="text-3xl font-bold leading-tight text-[#111827] md:text-4xl">
<div className="py-3"> Tìm kiếm
<h1 className="text-md md:text-lg font-semibold leading-6 text-gray-900"> </h1>
Search Results for: {query} <div className="mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]" />
</h1> {query ? (
</div> <p className="mt-4 text-sm text-[#5f6875]">
</div> Kết quả tìm kiếm cho: <span className="font-semibold text-[#111827]">{query}</span>
</p>
) : null}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="flex flex-col gap-10 xl:flex-row xl:gap-14">
<main className="lg:col-span-2 bg-background "> <main className="order-2 min-w-0 xl:order-1 xl:flex-1">
<div className="pb-5 overflow-hidden"> {postsQuery.isLoading ? (
{isLoading ? ( <div className="flex items-center justify-center py-16">
<div className="flex justify-center items-center py-12"> <Spinner className="size-8" />
<Spinner className="size-8" /> <span className="ml-2 text-gray-600">Đang tìm kiếm...</span>
<span className="ml-2 text-gray-600">Đang tìm kiếm...</span> </div>
</div> ) : (
) : ( <div className="space-y-9">
<> {rows.length ? (
{allData?.responseData.rows.map((news) => ( rows.map((item, index) => (
<CardNews <SearchResultItem key={item.id} item={item} index={index} />
key={news.id} ))
news={news} ) : (
link={news.external_link} <div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600">
/> Không tìm thấy bài viết phù hợp.
))}
<div className="w-full flex justify-center mt-4">
<Pagination
pageCount={Number(allData?.responseData.totalPages ?? 1)}
page={Number(allData?.responseData.currentPage ?? page)}
onChangePage={(p) => setPage(p)}
onGoToPreviousPage={() => setPage(Math.max(1, page - 1))}
onGoToNextPage={() =>
setPage(
Math.min(
Number(allData?.responseData.totalPages ?? 1),
page + 1
)
)
}
/>
</div> </div>
</> )}
)}
</div> <div className="flex w-full justify-center pt-2">
<Pagination
pageCount={totalPages}
page={currentPage}
onChangePage={(nextPage) => {
setPage(nextPage);
updateSearchUrl(query, nextPage);
}}
onGoToPreviousPage={() => {
const nextPage = Math.max(1, currentPage - 1);
setPage(nextPage);
updateSearchUrl(query, nextPage);
}}
onGoToNextPage={() => {
const nextPage = Math.min(totalPages, currentPage + 1);
setPage(nextPage);
updateSearchUrl(query, nextPage);
}}
/>
</div>
</div>
)}
</main> </main>
<aside className="space-y-6 order-first lg:order-last"> <aside className="order-1 space-y-5 xl:order-2 xl:w-[320px] xl:pt-0">
<div className="bg-white border rounded-md overflow-hidden hidden lg:block"> <form
<div className="w-full relative bg-gray-100"> className="rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]"
<img onSubmit={(event) => {
event.preventDefault();
setPage(1);
updateSearchUrl(searchInput, 1);
}}
>
<h2 className="text-lg font-bold text-[#111827]">Tìm kiếm</h2>
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Tên bài viết ..."
className="mt-4 h-11 rounded-xl border-[#edf1f5] bg-[#f8fafc] text-sm placeholder:text-gray-700"
/>
<div className="mt-4 grid grid-cols-2 gap-3">
<Button
type="submit"
className="h-11 rounded-xl bg-[#14519f] text-white hover:bg-[#0f4386]"
>
Tìm kiếm
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick={() => {
setSearchInput("");
setPage(1);
updateSearchUrl("", 1);
}}
>
Bỏ tìm
</Button>
</div>
</form>
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]">
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp" src="/banner.webp"
alt="Quảng cáo" alt="Đối tác quảng bá"
className="object-cover" width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên
</div>
</div>
</div> </div>
</div> </div>
</aside> </aside>
...@@ -110,14 +264,13 @@ function SearchContent() { ...@@ -110,14 +264,13 @@ function SearchContent() {
export default function Page() { export default function Page() {
return ( return (
<Suspense fallback={ <Suspense
<div className="min-h-screen container mx-auto p-4 flex items-center justify-center"> fallback={
<div className="text-center"> <div className="flex min-h-screen items-center justify-center bg-[#fbfbfa]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#063e8e] mx-auto"></div> <Spinner className="size-8" />
<p className="mt-4 text-gray-600">Loading search results...</p>
</div> </div>
</div> }
}> >
<SearchContent /> <SearchContent />
</Suspense> </Suspense>
); );
......
...@@ -556,7 +556,8 @@ export default function AdminBaseConfigPage() { ...@@ -556,7 +556,8 @@ export default function AdminBaseConfigPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-5"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-5">
<TabsList className="h-auto rounded-2xl bg-[#eaf2ff] p-1.5"> <div className="overflow-x-auto pb-1">
<TabsList className="h-auto min-w-max rounded-2xl bg-[#eaf2ff] p-1.5">
<TabsTrigger <TabsTrigger
value="branding" value="branding"
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]"
...@@ -581,7 +582,8 @@ export default function AdminBaseConfigPage() { ...@@ -581,7 +582,8 @@ export default function AdminBaseConfigPage() {
> >
Mạng xã hội Mạng xã hội
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div>
<TabsContent value="branding" className="mt-0"> <TabsContent value="branding" className="mt-0">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm"> <Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
...@@ -636,9 +638,9 @@ export default function AdminBaseConfigPage() { ...@@ -636,9 +638,9 @@ export default function AdminBaseConfigPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6 px-4 sm:px-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_360px]"> <div className="grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_360px]">
<div className="rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#f8fbff] to-white p-5"> <div className="rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#f8fbff] to-white p-4 sm:p-5">
<div className="relative flex min-h-[320px] items-center justify-center overflow-hidden rounded-[24px] border border-dashed border-[#063e8e]/18 bg-[#eef4ff]"> <div className="relative flex min-h-[320px] items-center justify-center overflow-hidden rounded-[24px] border border-dashed border-[#063e8e]/18 bg-[#eef4ff]">
{currentLogoMedia ? ( {currentLogoMedia ? (
<div className="relative h-[220px] w-[220px]"> <div className="relative h-[220px] w-[220px]">
...@@ -658,7 +660,7 @@ export default function AdminBaseConfigPage() { ...@@ -658,7 +660,7 @@ export default function AdminBaseConfigPage() {
</div> </div>
</div> </div>
<div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"> <div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5">
{currentLogo ? ( {currentLogo ? (
<div className="space-y-4 rounded-3xl border border-[#063e8e]/12 bg-white p-5 text-sm text-slate-600 shadow-sm"> <div className="space-y-4 rounded-3xl border border-[#063e8e]/12 bg-white p-5 text-sm text-slate-600 shadow-sm">
<div> <div>
...@@ -765,8 +767,8 @@ export default function AdminBaseConfigPage() { ...@@ -765,8 +767,8 @@ export default function AdminBaseConfigPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6 px-4 sm:px-6">
<div className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"> <div className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5">
<div className="relative aspect-[16/6] overflow-hidden rounded-[24px] border border-[#063e8e]/12 bg-[#eef4ff]"> <div className="relative aspect-[16/6] overflow-hidden rounded-[24px] border border-[#063e8e]/12 bg-[#eef4ff]">
{currentBannerMedia ? ( {currentBannerMedia ? (
<SafeNextImage <SafeNextImage
...@@ -819,7 +821,7 @@ export default function AdminBaseConfigPage() { ...@@ -819,7 +821,7 @@ export default function AdminBaseConfigPage() {
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{sortedBanners.map((item, index) => ( {sortedBanners.map((item, index) => (
<ConfigItemPreview <ConfigItemPreview
key={item.id} key={item.id}
...@@ -833,7 +835,7 @@ export default function AdminBaseConfigPage() { ...@@ -833,7 +835,7 @@ export default function AdminBaseConfigPage() {
</div> </div>
{currentBanner ? ( {currentBanner ? (
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"> <div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">Tên banner</div> <div className="text-xs uppercase tracking-[0.14em] text-gray-500">Tên banner</div>
<div className="mt-2 font-semibold text-[#163b73]">{currentBanner.name}</div> <div className="mt-2 font-semibold text-[#163b73]">{currentBanner.name}</div>
...@@ -899,8 +901,8 @@ export default function AdminBaseConfigPage() { ...@@ -899,8 +901,8 @@ export default function AdminBaseConfigPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)]"> <CardContent className="grid gap-6 px-4 sm:px-6 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"> <div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5">
<div className="text-sm font-semibold uppercase tracking-[0.15em] text-[#4b74b8]"> <div className="text-sm font-semibold uppercase tracking-[0.15em] text-[#4b74b8]">
Danh sách chi nhánh Danh sách chi nhánh
</div> </div>
...@@ -917,7 +919,7 @@ export default function AdminBaseConfigPage() { ...@@ -917,7 +919,7 @@ export default function AdminBaseConfigPage() {
</div> </div>
</div> </div>
<div className="space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"> <div className="space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5">
{currentBranch ? ( {currentBranch ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
...@@ -983,7 +985,7 @@ export default function AdminBaseConfigPage() { ...@@ -983,7 +985,7 @@ export default function AdminBaseConfigPage() {
)} )}
</div> </div>
<div className="hidden rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#063e8e] to-[#0f4a9f] p-6 text-white shadow-[0_16px_30px_rgba(6,62,142,0.18)]"> <div className="hidden rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#063e8e] to-[#0f4a9f] p-6 text-white shadow-[0_16px_30px_rgba(6,62,142,0.18)] lg:block">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-white/75"> <div className="text-xs font-semibold uppercase tracking-[0.18em] text-white/75">
Preview chi nhánh Preview chi nhánh
</div> </div>
...@@ -1034,16 +1036,16 @@ export default function AdminBaseConfigPage() { ...@@ -1034,16 +1036,16 @@ 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" />
Luu cấu hình Lưu cấu hình
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4 px-4 sm:px-6">
{sortedSocials.map((item) => ( {sortedSocials.map((item) => (
<div <div
key={item.id} key={item.id}
className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5" className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5"
> >
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end"> <div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end">
<div className="flex items-center gap-3 rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"> <div className="flex items-center gap-3 rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
......
...@@ -12,15 +12,35 @@ import { useSidebarStore } from '@/hooks/use-admin-sidebar'; ...@@ -12,15 +12,35 @@ import { useSidebarStore } from '@/hooks/use-admin-sidebar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function AdminShell({ children }: { children: React.ReactNode }) { function AdminShell({ children }: { children: React.ReactNode }) {
const { isOpen } = useSidebarStore(); const { close, isOpen } = useSidebarStore();
React.useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 1023px)');
const syncSidebar = () => {
if (mediaQuery.matches) close();
};
syncSidebar();
mediaQuery.addEventListener('change', syncSidebar);
return () => mediaQuery.removeEventListener('change', syncSidebar);
}, [close]);
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen overflow-x-hidden bg-white">
<AdminSidebar /> <AdminSidebar />
{isOpen ? (
<button
type="button"
aria-label="Close sidebar"
className="fixed inset-0 z-30 bg-slate-950/35 backdrop-blur-[1px] lg:hidden"
onClick={close}
/>
) : null}
<div <div
className={cn( className={cn(
'transition-all duration-300', 'min-w-0 transition-all duration-300',
isOpen ? 'pl-72' : 'pl-24', isOpen ? 'lg:pl-72' : 'lg:pl-24',
)} )}
> >
<AdminHeader /> <AdminHeader />
......
...@@ -52,6 +52,19 @@ ...@@ -52,6 +52,19 @@
--tiptap-rose: oklch(0.72 0.17 13); --tiptap-rose: oklch(0.72 0.17 13);
} }
html,
body {
max-width: 100%;
overflow-x: hidden;
}
img,
video,
canvas,
svg {
max-width: 100%;
}
@layer components { @layer components {
.tiptap { .tiptap {
@apply border-gray-200; @apply border-gray-200;
......
...@@ -17,7 +17,7 @@ export function AdminStatsGrid({ items, className }: AdminStatsGridProps) { ...@@ -17,7 +17,7 @@ export function AdminStatsGrid({ items, className }: AdminStatsGridProps) {
const gridClassName = const gridClassName =
className ?? className ??
(items.length === 3 (items.length === 3
? "grid grid-cols-1 gap-4 md:grid-cols-3" ? "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"
: "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"); : "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4");
return ( return (
...@@ -28,9 +28,9 @@ export function AdminStatsGrid({ items, className }: AdminStatsGridProps) { ...@@ -28,9 +28,9 @@ export function AdminStatsGrid({ items, className }: AdminStatsGridProps) {
className="rounded-2xl border border-[#063e8e]/15 bg-white px-5 py-4 shadow-sm" className="rounded-2xl border border-[#063e8e]/15 bg-white px-5 py-4 shadow-sm"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-2"> <div className="min-w-0 space-y-2">
<p className="text-sm font-medium text-gray-700">{item.label}</p> <p className="text-sm font-medium text-gray-700">{item.label}</p>
<div className="text-3xl font-semibold leading-none text-black">{item.value}</div> <div className="break-words text-2xl font-semibold leading-none text-black sm:text-3xl">{item.value}</div>
</div> </div>
{item.icon ? ( {item.icon ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#063e8e]/10"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#063e8e]/10">
......
...@@ -33,25 +33,25 @@ export function AdminTableLayout({ ...@@ -33,25 +33,25 @@ export function AdminTableLayout({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center"> <div className="flex min-w-0 flex-1 flex-col gap-3 lg:flex-row lg:items-center">
<Input <Input
value={searchValue} value={searchValue}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
onChange={(event) => onSearchChange(event.target.value)} onChange={(event) => onSearchChange(event.target.value)}
className="max-w-sm rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700" className="w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 sm:max-w-sm"
/> />
{filters} {filters}
</div> </div>
{actionLabel || actionMeta ? ( {actionLabel || actionMeta ? (
<div className="flex items-center gap-3 self-start sm:self-auto"> <div className="flex w-full flex-wrap items-center gap-3 self-start sm:w-auto sm:self-auto">
{actionMeta} {actionMeta}
{actionLabel ? ( {actionLabel ? (
<Button <Button
type="button" type="button"
disabled={actionDisabled} disabled={actionDisabled}
onClick={onActionClick} onClick={onActionClick}
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90" className="w-full bg-[#063e8e] text-white hover:bg-[#063e8e]/90 sm:w-auto"
> >
{actionIcon ?? <Plus className="mr-2 h-4 w-4" />} {actionIcon ?? <Plus className="mr-2 h-4 w-4" />}
{actionLabel} {actionLabel}
...@@ -61,9 +61,9 @@ export function AdminTableLayout({ ...@@ -61,9 +61,9 @@ export function AdminTableLayout({
) : null} ) : null}
</div> </div>
<div className="overflow-hidden rounded-xl border border-[#063e8e]/20 bg-white shadow-sm [&_tbody_td:not(:last-child)]:border-r [&_tbody_td:not(:last-child)]:border-[#063e8e]/20 [&_thead_th:not(:last-child)]:border-r [&_thead_th:not(:last-child)]:border-white/15"> <div className="overflow-x-auto rounded-xl border border-[#063e8e]/20 bg-white shadow-sm [&_table]:min-w-[760px] [&_tbody_td:not(:last-child)]:border-r [&_tbody_td:not(:last-child)]:border-[#063e8e]/20 [&_thead_th:not(:last-child)]:border-r [&_thead_th:not(:last-child)]:border-white/15">
{children} {children}
</div> </div>
</div> </div>
); );
} }
\ No newline at end of file
...@@ -67,8 +67,8 @@ export function AdminHeader() { ...@@ -67,8 +67,8 @@ export function AdminHeader() {
return ( return (
<header className="sticky top-0 z-30 border-b border-[#063e8e]/15 bg-background/95 shadow-sm backdrop-blur supports-backdrop-filter:bg-background/80"> <header className="sticky top-0 z-30 border-b border-[#063e8e]/15 bg-background/95 shadow-sm backdrop-blur supports-backdrop-filter:bg-background/80">
<div className="flex h-16 items-center justify-between px-4 lg:px-6"> <div className="flex min-h-16 items-center justify-between gap-3 px-3 py-2 sm:px-4 lg:px-6">
<div className="flex items-center gap-4"> <div className="flex min-w-0 items-center gap-2 sm:gap-4">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
...@@ -78,11 +78,11 @@ export function AdminHeader() { ...@@ -78,11 +78,11 @@ export function AdminHeader() {
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
<h1 className="text-xl font-bold text-[#063e8e]">{title}</h1> <h1 className="truncate text-base font-bold text-[#063e8e] sm:text-xl">{title}</h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex shrink-0 items-center gap-2 sm: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="hidden items-center gap-2 rounded-full border border-[#063e8e]/10 bg-[#f8fbff] px-3 py-1.5 text-sm font-medium text-[#163b73] sm:flex">
<ShieldCheck className="h-4 w-4 text-[#063e8e]" /> <ShieldCheck className="h-4 w-4 text-[#063e8e]" />
<span>{formatRoles(currentUser?.roles)}</span> <span>{formatRoles(currentUser?.roles)}</span>
</div> </div>
...@@ -93,7 +93,7 @@ export function AdminHeader() { ...@@ -93,7 +93,7 @@ export function AdminHeader() {
className="border-[#063e8e]/15 text-[#063e8e]" className="border-[#063e8e]/15 text-[#063e8e]"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Đăng xuất <span className="hidden sm:inline">Đăng xuất</span>
</Button> </Button>
</div> </div>
</div> </div>
......
...@@ -70,7 +70,7 @@ const membersReservedSegments = new Set(['fields', 'regions']); ...@@ -70,7 +70,7 @@ const membersReservedSegments = new Set(['fields', 'regions']);
export function AdminSidebar() { export function AdminSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { isOpen } = useSidebarStore(); const { close, isOpen } = useSidebarStore();
const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({}); const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({});
const isItemActive = React.useCallback( const isItemActive = React.useCallback(
...@@ -93,17 +93,22 @@ export function AdminSidebar() { ...@@ -93,17 +93,22 @@ export function AdminSidebar() {
const toggleGroup = (name: string) => const toggleGroup = (name: string) =>
setExpandedGroups((previous) => ({ ...previous, [name]: !previous[name] })); setExpandedGroups((previous) => ({ ...previous, [name]: !previous[name] }));
const handleMobileNavigate = () => {
if (window.innerWidth < 1024) close();
};
return ( return (
<aside <aside
className={cn( className={cn(
'fixed left-0 top-0 z-40 h-screen border-r border-[#063e8e]/10 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition-all duration-300', 'fixed left-0 top-0 z-40 h-dvh border-r border-[#063e8e]/10 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition-all duration-300',
isOpen ? 'w-72' : 'w-24', isOpen ? 'w-72 translate-x-0 lg:w-72' : '-translate-x-full lg:w-24 lg:translate-x-0',
)} )}
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className={cn('px-4 pb-4 pt-5', !isOpen && 'px-3')}> <div className={cn('px-4 pb-4 pt-5', !isOpen && 'px-3')}>
<Link <Link
href="/admin/base-config" href="/admin/base-config"
onClick={handleMobileNavigate}
className={cn( className={cn(
'flex items-center backdrop-blur-sm', 'flex items-center backdrop-blur-sm',
isOpen isOpen
...@@ -189,6 +194,7 @@ export function AdminSidebar() { ...@@ -189,6 +194,7 @@ export function AdminSidebar() {
<Link <Link
key={child.name} key={child.name}
href={child.href} href={child.href}
onClick={handleMobileNavigate}
className={cn( className={cn(
'group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all', 'group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all',
childActive childActive
...@@ -212,6 +218,7 @@ export function AdminSidebar() { ...@@ -212,6 +218,7 @@ export function AdminSidebar() {
<Link <Link
key={item.name} key={item.name}
href={item.href || '#'} href={item.href || '#'}
onClick={handleMobileNavigate}
title={!isOpen ? item.name : undefined} title={!isOpen ? item.name : undefined}
className={cn( className={cn(
'flex items-center rounded-2xl text-sm font-medium transition-all duration-200', 'flex items-center rounded-2xl text-sm font-medium transition-all duration-200',
...@@ -233,6 +240,7 @@ export function AdminSidebar() { ...@@ -233,6 +240,7 @@ export function AdminSidebar() {
<div className="rounded-[28px] border border-white/80 bg-white/95 p-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]"> <div className="rounded-[28px] border border-white/80 bg-white/95 p-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]">
<Link <Link
href="/" href="/"
onClick={handleMobileNavigate}
className="flex items-center gap-3 text-sm font-semibold text-[#063e8e] transition hover:opacity-80" className="flex items-center gap-3 text-sm font-semibold text-[#063e8e] transition hover:opacity-80"
> >
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]"> <div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]">
...@@ -250,6 +258,7 @@ export function AdminSidebar() { ...@@ -250,6 +258,7 @@ export function AdminSidebar() {
) : ( ) : (
<Link <Link
href="/" href="/"
onClick={handleMobileNavigate}
title="Về trang chủ" title="Về trang chủ"
className="mx-auto flex h-14 w-14 items-center justify-center rounded-[22px] border border-white/80 bg-white/95 text-[#063e8e] shadow-sm transition hover:bg-white" className="mx-auto flex h-14 w-14 items-center justify-center rounded-[22px] border border-white/80 bg-white/95 text-[#063e8e] shadow-sm transition hover:bg-white"
> >
......
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