Commit 54b553bc authored by Phạm Quang Bảo's avatar Phạm Quang Bảo

feat: replace ImageNext with native Next.js Image component and update UI styling

- Replace custom ImageNext component with next/image across all home page components
- Add img.youtube.com and news.vccihcm.vn to Next.js image remote patterns
- Implement onError fallback handlers for Image components
- Update border-radius values from 18-28px to consistent 14-16px across components
- Remove unused CardNews and CardEvent components
- Replace "Xem tất cả" text links with ChevronRight icons
- Adjust spacing and alignment in section headers (items-start →
parent 290840a0
......@@ -27,6 +27,18 @@ const nextConfig: NextConfig = {
port: "",
pathname: "/images/**",
},
{
protocol: "https",
hostname: "img.youtube.com",
port: "",
pathname: "/vi/**",
},
{
protocol: "https",
hostname: "news.vccihcm.vn",
port: "",
pathname: "/uploads/**",
},
],
},
};
......
"use client";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay } from "swiper/modules";
import { Swiper as SwiperType } from "swiper/types";
......@@ -84,13 +84,18 @@ function BannerSlideItem({
: "/thumbnail.png";
return (
<ImageNext
<Image
src={url}
alt={alt}
width={2560}
height={720}
sizes="100vw"
fallback="/thumbnail.png"
onError={(e) => {
const img = e.currentTarget as HTMLImageElement;
if (!img.src.includes("thumbnail.png")) {
img.src = "/thumbnail.png";
}
}}
className="w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px] object-cover"
/>
);
......
import { NewsItem } from "@/api/vcci-news/types/news";
import links from "@/links";
import dayjs from "dayjs";
import AppEditorContent from "@/components/shared/editor-content";
import Link from "next/link";
import ImageNext from "@/components/shared/image-next";
function CardNews({ news }: { news: NewsItem }) {
return (
<Link
href={`${news.external_link}`}
className="flex flex-row gap-2 mb-2 sm:gap-3 sm:mb-3"
>
<ImageNext
src={links.resolveImageUrl(news.thumbnail) || "/img-error.png"}
alt={news.title}
className="aspect-3/2 object-cover"
width={130}
height={86}
/>
<div className="flex-1">
<p className="text-[#363636] font-bold text-sm line-clamp-2">
{news.title}
</p>
<p className="text-gray-500 text-sm my-1">
{dayjs(news.release_at).format("DD/MM/YYYY")}
</p>
{/* <AppEditorContent className='line-clamp-2' value={news.description} /> */}
</div>
</Link>
);
}
export default CardNews;
......@@ -36,9 +36,8 @@ function BusinessOpportunities() {
<Link
key={item.id}
href={item.externalLink}
className={`group flex min-h-[58px] gap-3 rounded-[18px] px-4 py-3 transition-all duration-200 hover:bg-[#f5f7fb] hover:shadow-[0_10px_24px_rgba(36,70,156,0.08)] ${
index === 0 ? "pt-3.5" : ""
}`}
className={`group flex min-h-[58px] gap-3 rounded-[16px] pr-4 py-3 transition-all duration-200 hover:bg-[#f5f7fb] hover:shadow-[0_10px_24px_rgba(36,70,156,0.08)] ${index === 0 ? "pt-3.5" : ""
}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500] transition-opacity duration-200 group-hover:opacity-0" />
......@@ -63,7 +62,7 @@ function BusinessOpportunities() {
) : (
<div
key={`business-placeholder-${index}`}
className={`flex min-h-[58px] gap-3 rounded-[14px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`}
className={`flex min-h-[58px] gap-3 rounded-[16px] 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">
......
......@@ -132,7 +132,7 @@ function EventsCalendar({
return (
<aside
className={cn(
"w-full rounded-[28px] bg-white text-[#24469c] shadow-[0_18px_38px_rgba(16,61,130,0.16)]",
"w-full rounded-[16px] 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]",
)}
......@@ -181,9 +181,9 @@ function EventsCalendar({
<div className={cn("mt-3 h-[4px] w-[60px] rounded-full bg-[#f7b500]", compact && "mt-2.5")} />
<div className={cn("mt-4 border-t border-[#ebf0f8] pt-3.5", compact && "mt-3")}>
<div className={cn("mt-4 pt-3.5", compact && "mt-3")}>
{eventCalendarQuery.isLoading ? (
<div className="mb-3 rounded-[16px] bg-[#f7f9fd] p-3 text-[12px] text-[#3d547f]">
<div className="mb-3 rounded-[14px] bg-[#f7f9fd] p-3 text-[12px] text-[#3d547f]">
Đang tải dữ liệu tháng này...
</div>
) : null}
......@@ -226,9 +226,9 @@ function EventsCalendar({
: "cursor-default",
selected && "ring-2 ring-[#f7b500] ring-offset-2 ring-offset-white",
isToday &&
selectable &&
!selected &&
"ring-2 ring-[#9fb3db] ring-offset-2 ring-offset-white",
selectable &&
!selected &&
"ring-2 ring-[#9fb3db] ring-offset-2 ring-offset-white",
)}
>
{format(day, "d")}
......@@ -252,7 +252,7 @@ function EventsCalendar({
side="top"
align="center"
sideOffset={12}
className="hidden w-[min(360px,calc(100vw-2rem))] overflow-hidden rounded-[20px] border border-[#d9e3f2] bg-white p-0 text-[#234171] shadow-[0_20px_45px_rgba(16,61,130,0.18)] lg:block"
className="hidden w-[min(360px,calc(100vw-2rem))] overflow-hidden rounded-[14px] border border-[#d9e3f2] bg-white p-0 text-[#234171] shadow-[0_20px_45px_rgba(16,61,130,0.18)] lg:block"
>
<div className="flex items-center justify-between gap-3 border-b border-[#edf2f9] px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#7f8eab]">
......@@ -268,7 +268,7 @@ function EventsCalendar({
<Link
key={item.id}
href={item.externalLink || "#"}
className="block rounded-[16px] border border-[#e3ebf8] bg-[#fbfdff] px-3.5 py-3 transition-colors hover:border-[#c9d7ee] hover:bg-white"
className="block rounded-[14px] border border-[#e3ebf8] bg-[#fbfdff] px-3.5 py-3 transition-colors hover:border-[#c9d7ee] hover:bg-white"
>
<div className="flex items-start gap-2.5">
<span
......@@ -327,7 +327,7 @@ function EventsCalendar({
</div>
{highlightedEvents.length > 0 ? (
<div className="mt-4 rounded-2xl bg-[#f7f9fd] p-3.5 text-[12px] leading-5 text-[#3d547f] lg:hidden">
<div className="mt-4 rounded-[14px] bg-[#f7f9fd] p-3.5 text-[12px] leading-5 text-[#3d547f] lg:hidden">
{selectedEvents.length > 1 ? (
<div className="mb-3 flex items-center justify-between gap-3 border-b border-[#e5edf8] pb-2.5">
<p className="text-[12px] font-semibold text-[#24469c]">
......
import { EventItem } from '@/api/vcci-news/types/event'
import links from '@/links'
import dayjs from 'dayjs';
import AppEditorContent from '@/components/shared/editor-content';
import Link from "next/link";
import ImageNext from "@/components/shared/image-next";
function CardEvent({ event }: { event: EventItem }) {
return (
<Link
href={`hoat-dong/su-kien/${event.id}`}
className='flex flex-row gap-2 mb-2 sm:gap-3 sm:mb-3 p-2 sm:p-3 border border-gray-200 bg-white rounded-md'
>
<ImageNext
src={links.resolveImageUrl(event.image) || "/img-error.png"}
alt={event.name}
className='aspect-3/2 object-cover'
width={130}
height={86}
/>
<div className='flex-1'>
<p className='text-[#0056b3] font-bold text-sm line-clamp-2'>
{event.name}
</p>
<p className='text-gray-500 text-sm my-1'>
{dayjs(event.start_time).format('DD/MM/YYYY')}
</p>
{/* <AppEditorContent className='line-clamp-2' value={event.description} /> */}
</div>
</Link>
);
}
export default CardEvent;
'use client';
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import Link from "next/link";
import { useMemo } from "react";
import { ChevronRight } from "lucide-react";
import { getFallbackImage } from "@/lib/utils/fallback-image";
function Events() {
......@@ -23,8 +24,8 @@ function Events() {
);
return (
<div className="flex-1 rounded-[28px] bg-linear-to-br from-[#14488f] to-[#2d67bf] p-4 text-white shadow-[0_18px_38px_rgba(16,61,130,0.24)] md:p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<div className="flex-1 rounded-[16px] bg-linear-to-br from-[#14488f] to-[#2d67bf] p-4 text-white shadow-[0_18px_38px_rgba(16,61,130,0.24)] md:p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h2 className="client-section-title uppercase text-white">
Sự kiện sắp diễn ra
......@@ -34,9 +35,9 @@ function Events() {
<Link
href={eventsLink}
className="pt-1.5 text-sm font-semibold text-[#ffd34f] transition-colors hover:text-white"
className="text-[#ffd34f] transition-colors hover:text-white"
>
Xem sự kiện
<ChevronRight className="h-5 w-5" />
</Link>
</div>
......@@ -44,10 +45,10 @@ function Events() {
{featuredEvent ? (
<Link
href={featuredEvent.externalLink}
className="group flex h-full cursor-pointer flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
className="group flex h-full cursor-pointer flex-col overflow-hidden rounded-[14px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
>
<div className="relative h-[180px] overflow-hidden md:h-[220px] xl:h-[248px]">
<ImageNext
<Image
src={featuredEvent.thumbnail?.url ?? featuredFallback}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720}
......@@ -95,7 +96,7 @@ function Events() {
</div>
</Link>
) : (
<div className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.12)]">
<div className="flex h-full flex-col overflow-hidden rounded-[14px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.12)]">
<div className="h-[180px] bg-[#d7e3f9] md:h-[220px] xl:h-[248px]" />
<div className="space-y-2 p-3 pt-2.5">
<div className="h-6 w-5/6 rounded bg-[#e7eefb]" />
......@@ -110,10 +111,10 @@ function Events() {
<Link
key={item.id}
href={item.externalLink}
className="group flex flex-1 cursor-pointer items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
className="group flex flex-1 cursor-pointer items-center gap-3 rounded-[14px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
>
<div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[12px]">
<ImageNext
<div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[14px]">
<Image
src={item.thumbnail?.url ?? sideFallbacks[index]}
alt={item.thumbnail?.alt || item.title}
width={160}
......@@ -170,9 +171,9 @@ function Events() {
) : (
<div
key={`event-placeholder-${index}`}
className="flex flex-1 items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]"
className="flex flex-1 items-center gap-3 rounded-[14px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]"
>
<div className="h-[64px] w-[64px] shrink-0 rounded-[12px] bg-white/20" />
<div className="h-[64px] w-[64px] shrink-0 rounded-[14px] bg-white/20" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-white/25" />
<div className="mt-2 h-3 w-20 rounded bg-white/20" />
......
'use client';
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
......@@ -28,7 +28,7 @@ function FeaturedNews() {
return (
<section className="py-8 md:py-10">
<div className="w-full">
<div className="mb-8 flex items-start justify-between gap-4">
<div className="mb-8 flex items-center justify-between gap-4">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Tin nổi bật
......@@ -38,10 +38,9 @@ function FeaturedNews() {
<Link
href={featuredOverviewLink}
className="inline-flex items-center gap-2 pt-2 text-base font-semibold text-[#2b56c0] transition-colors hover:text-[#173f9f]"
className="text-[#2b56c0] transition-colors hover:text-[#173f9f]"
>
<span>Xem tất cả</span>
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-5 w-5" />
</Link>
</div>
......@@ -49,10 +48,10 @@ function FeaturedNews() {
{primaryItem ? (
<Link
href={primaryItem.externalLink}
className="group relative block cursor-pointer overflow-hidden rounded-[20px] bg-[#0d2f5f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:rounded-[24px] md:min-h-[320px] lg:min-h-[380px]"
className="group relative block cursor-pointer overflow-hidden rounded-[16px] bg-[#0d2f5f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:rounded-[16px] md:min-h-[320px] lg:min-h-[380px]"
>
<div className="relative h-full min-h-[195px] md:min-h-[320px] lg:min-h-[380px]">
<ImageNext
<Image
src={primaryItem.thumbnail?.url ?? primaryFallback}
alt={primaryItem.thumbnail?.alt || primaryItem.title}
width={1200}
......@@ -79,7 +78,7 @@ function FeaturedNews() {
</div>
</Link>
) : (
<div className="relative overflow-hidden rounded-[20px] bg-[#e9eef8] shadow-[0_16px_32px_rgba(28,52,120,0.12)] md:rounded-3xl md:min-h-[320px] lg:min-h-[380px]">
<div className="relative overflow-hidden rounded-[16px] bg-[#e9eef8] shadow-[0_16px_32px_rgba(28,52,120,0.12)] md:rounded-[16px] md:min-h-[320px] lg:min-h-[380px]">
<div className="flex h-full min-h-[195px] flex-col justify-end p-3.5 md:min-h-80 md:p-5 lg:min-h-[380px]">
<span className="mb-2 h-8 w-28 rounded-[10px] bg-white/80" />
<div className="h-8 w-3/4 rounded bg-white/90 md:h-10" />
......@@ -95,10 +94,10 @@ function FeaturedNews() {
<Link
key={item.id}
href={item.externalLink}
className="group relative block cursor-pointer overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] min-h-[165px]"
className="group relative block cursor-pointer overflow-hidden rounded-[16px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] min-h-[165px]"
>
<div className="relative flex h-full min-h-[165px]">
<ImageNext
<Image
src={item.thumbnail?.url ?? secondaryFallbacks[index]}
alt={item.thumbnail?.alt || item.title}
width={600}
......@@ -127,7 +126,7 @@ function FeaturedNews() {
) : (
<div
key={`featured-placeholder-${index}`}
className="rounded-[20px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] min-h-[165px]"
className="rounded-[16px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] min-h-[165px]"
>
<div className="flex h-full min-h-[165px] flex-col justify-end p-3.5">
<span className="mb-2 h-7 w-24 rounded-[10px] bg-white/80" />
......
......@@ -32,7 +32,7 @@ function HorizontalAdBanner() {
href={href}
target="_blank"
rel="noopener noreferrer"
className="relative block overflow-hidden rounded-[10px] shadow-[0_16px_32px_rgba(28,52,120,0.2)] lg:rounded-[20px]"
className="relative block overflow-hidden rounded-[16px] shadow-[0_16px_32px_rgba(28,52,120,0.2)]"
style={{ aspectRatio: "1600 / 200" }}
title={title}
>
......@@ -40,6 +40,7 @@ function HorizontalAdBanner() {
src={src}
alt={alt}
fill
sizes="100vw"
className="object-cover"
unoptimized={isGif}
onError={() => {
......
'use client';
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { ChevronRight } from "lucide-react";
import memberImages from "@/constants/memberImages";
import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/app/api/mock-data";
import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/mockdata/bff-fallback";
import { useGetOrganizations } from "@/api/vcci-hcm/endpoints/organizations";
import type { Organization } from "@/api/vcci-hcm/models";
import Link from "next/link";
......@@ -70,7 +71,7 @@ function Members() {
const renderMemberContent = () => {
if (featuredMembersLoading) {
return (
<div className="rounded-[16px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70">
<div className="rounded-[14px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70">
Đang tải dữ liệu...
</div>
);
......@@ -78,7 +79,7 @@ function Members() {
if (displayMembers.length === 0) {
return (
<div className="rounded-[16px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70">
<div className="rounded-[14px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70">
Chưa có thông tin.
</div>
);
......@@ -102,7 +103,7 @@ function Members() {
key={member.id}
className="!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
<article className="rounded-[20px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]">
<article className="rounded-[14px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]">
{detailUrl ? (
<a
href={detailUrl}
......@@ -112,9 +113,9 @@ function Members() {
>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<ImageNext
src={resolveMemberImage(member.avatar, index)}
alt={member.name}
<Image
src={resolveMemberImage(member.avatar, index) ?? "/img-error.png"}
alt={member.name ?? ""}
width={260}
height={180}
className="h-[180px] w-[260px] max-w-full object-contain"
......@@ -129,9 +130,9 @@ function Members() {
<>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<ImageNext
src={resolveMemberImage(member.avatar, index)}
alt={member.name}
<Image
src={resolveMemberImage(member.avatar, index) ?? "/img-error.png"}
alt={member.name ?? ""}
width={260}
height={180}
className="h-[180px] w-[260px] max-w-full object-contain"
......@@ -153,8 +154,8 @@ function Members() {
return (
<section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch">
<aside className="flex-1 rounded-[22px] bg-[#f7b500] p-4 shadow-[0_18px_34px_rgba(247,181,0,0.18)] md:p-5">
<div className="flex items-start justify-between gap-3">
<aside className="flex-1 rounded-[16px] bg-[#f7b500] p-4 shadow-[0_18px_34px_rgba(247,181,0,0.18)] md:p-5">
<div className="flex items-center justify-between gap-3 pb-10">
<div>
<h2 className="client-section-title uppercase text-[#20449a]">
Hội viên tiêu biểu
......@@ -166,19 +167,17 @@ function Members() {
href={FEATURED_MEMBER_MORE_URL}
target="_blank"
rel="noreferrer"
className="pt-1 text-sm font-semibold text-[#1e2f5e] transition-colors hover:text-[#20449a]"
className="text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
Xem thêm
<ChevronRight className="h-5 w-5" />
</Link>
</div>
<div className="mt-4 border-t border-[#e7aa00] pt-5" />
{renderMemberContent()}
</aside>
<aside className="w-full xl:w-[31%] xl:min-w-[320px]">
<div className="mb-4 flex items-start justify-between gap-3">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Kết nối hội viên
......@@ -188,9 +187,9 @@ function Members() {
<Link
href={sectionLink}
className="pt-1 text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1]"
className="text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
Xem tất cả
<ChevronRight className="h-5 w-5" />
</Link>
</div>
......@@ -204,16 +203,16 @@ function Members() {
autoplay={{ delay: 4000, disableOnInteraction: false }}
loop={connectionPosts.length > 1}
slidesPerView={1}
className="w-full overflow-hidden rounded-[20px]"
className="w-full overflow-hidden rounded-[14px]"
>
{connectionPosts.map((item) => (
<SwiperSlide key={item.id}>
<Link
href={item.externalLink}
className="group relative block cursor-pointer overflow-hidden rounded-[20px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
className="group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[16/10] overflow-hidden xl:aspect-[1.25/1]">
<ImageNext
<Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title}
width={520}
......@@ -252,10 +251,10 @@ function Members() {
<Link
key={item.id}
href={item.externalLink}
className="group relative block cursor-pointer overflow-hidden rounded-[20px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
className="group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[16/10] overflow-hidden">
<ImageNext
<Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title}
width={520}
......@@ -287,7 +286,7 @@ function Members() {
</div>
</>
) : (
<div className="rounded-[16px] bg-[#eef3fb] px-5 py-10 text-center text-sm text-[#7f8eab]">
<div className="rounded-[14px] bg-[#eef3fb] px-5 py-10 text-center text-sm text-[#7f8eab]">
Chưa có thông tin.
</div>
)}
......
import { NewsItem } from "@/api/vcci-news/types/news";
import links from "@/links";
import dayjs from "dayjs";
import AppEditorContent from "@/components/shared/editor-content";
import Link from "next/link";
import ImageNext from "@/components/shared/image-next";
function CardNews({ news }: { news: NewsItem }) {
return (
<Link
href={`${news.external_link}`}
className="flex flex-row gap-2 mb-2 sm:gap-3 sm:mb-3"
>
<ImageNext
src={links.resolveImageUrl(news.thumbnail) || "/img-error.png"}
alt={news.title}
className="aspect-3/2 object-cover"
width={130}
height={86}
/>
<div className="flex-1">
<p className="text-[#363636] font-bold text-sm line-clamp-2">
{news.title}
</p>
<p className="text-gray-500 text-sm my-1">
{dayjs(news.release_at).format("DD/MM/YYYY")}
</p>
{/* <AppEditorContent className='line-clamp-2' value={news.description} /> */}
</div>
</Link>
);
}
export default CardNews;
'use client';
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
const tabs = [
......@@ -37,7 +38,7 @@ function News() {
return (
<div className="flex-1">
<div className="mb-6 flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="mb-6 flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h2 className="client-section-title uppercase text-[#24469c]">
Tin tức
......@@ -55,8 +56,8 @@ function News() {
type="button"
onClick={() => setTab(item.id)}
className={`rounded-full px-5 py-2.5 text-[14px] font-semibold transition-all ${active
? "bg-[#1f5ba9] text-white shadow-[0_10px_20px_rgba(31,91,169,0.18)]"
: "bg-[#f4f7fb] text-[#7f8eab] hover:bg-[#eaf0f8]"
? "bg-[#1f5ba9] text-white shadow-[0_10px_20px_rgba(31,91,169,0.18)]"
: "bg-[#f4f7fb] text-[#7f8eab] hover:bg-[#eaf0f8]"
}`}
>
{item.label}
......@@ -65,9 +66,9 @@ function News() {
})}
<Link
href={overviewLink}
className="ml-auto text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1] xl:hidden"
className="ml-auto text-[#24469c] transition-colors hover:text-[#1b55a1] xl:hidden"
>
Xem tất cả
<ChevronRight className="h-5 w-5" />
</Link>
</div>
</div>
......@@ -79,10 +80,10 @@ function News() {
{featuredArticle ? (
<Link
href={featuredArticle.externalLink}
className="group block cursor-pointer overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
className="group block cursor-pointer overflow-hidden rounded-[16px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="relative aspect-[1.4/1] overflow-hidden">
<ImageNext
<Image
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
......@@ -128,7 +129,7 @@ function News() {
</div>
</Link>
) : (
<div className="overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]">
<div className="overflow-hidden rounded-[16px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]">
<div className="aspect-[1.75/1] bg-[#eef3fb]" />
<div className="space-y-2 p-3">
<div className="h-5 w-24 rounded bg-[#eef3fb]" />
......@@ -147,10 +148,10 @@ function News() {
<Link
key={news.id}
href={news.externalLink}
className="group flex flex-1 cursor-pointer items-center gap-3 rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)]"
className="group flex flex-1 cursor-pointer items-center gap-3 rounded-[16px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)]"
>
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-xl">
<ImageNext
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-[14px]">
<Image
src={news.thumbnail?.url ?? "/thumbnail.png"}
alt={news.thumbnail?.alt || news.title}
width={160}
......@@ -203,9 +204,9 @@ function News() {
) : (
<div
key={`news-placeholder-${index}`}
className="flex flex-1 items-center gap-3 rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.06)]"
className="flex flex-1 items-center gap-3 rounded-[16px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.06)]"
>
<div className="h-16 w-16 shrink-0 rounded-xl bg-[#eef3fb]" />
<div className="h-20 w-20 shrink-0 rounded-[14px] bg-[#eef3fb]" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-[#eef3fb]" />
<div className="mt-1 h-4 w-24 rounded bg-[#f4f7fb]" />
......@@ -223,10 +224,10 @@ function News() {
{featuredArticle ? (
<Link
href={featuredArticle.externalLink}
className="group block cursor-pointer overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
className="group block cursor-pointer overflow-hidden rounded-[16px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="relative aspect-[16/9] overflow-hidden">
<ImageNext
<Image
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
......@@ -272,7 +273,7 @@ function News() {
</div>
</Link>
) : (
<div className="overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]">
<div className="overflow-hidden rounded-[16px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]">
<div className="aspect-[16/9] bg-[#eef3fb]" />
<div className="space-y-2 p-3">
<div className="h-5 w-24 rounded bg-[#eef3fb]" />
......@@ -288,10 +289,10 @@ function News() {
<Link
key={news.id}
href={news.externalLink}
className="group flex cursor-pointer items-center gap-3 rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)]"
className="group flex cursor-pointer items-center gap-3 rounded-[16px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)]"
>
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-xl">
<ImageNext
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-[14px]">
<Image
src={news.thumbnail?.url ?? "/thumbnail.png"}
alt={news.thumbnail?.alt || news.title}
width={160}
......@@ -346,9 +347,9 @@ function News() {
) : (
<div
key={`news-placeholder-${index}`}
className="flex items-center gap-3 rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.06)]"
className="flex items-center gap-3 rounded-[16px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.06)]"
>
<div className="h-16 w-16 shrink-0 rounded-xl bg-[#eef3fb]" />
<div className="h-20 w-20 shrink-0 rounded-[14px] bg-[#eef3fb]" />
<div className="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-[#eef3fb]" />
<div className="mt-2 h-3 w-24 rounded bg-[#f4f7fb]" />
......
import { NewsItem } from "@/api/vcci-news/types/news";
import links from "@/links";
import dayjs from "dayjs";
import Link from "next/link";
import ImageNext from "@/components/shared/image-next";
function CardNews({ news }: { news: NewsItem }) {
return (
<Link
href={`${news.external_link}`}
className="flex flex-row gap-2 mb-2 sm:gap-3 sm:mb-3"
>
<ImageNext
src={links.resolveImageUrl(news.thumbnail) || "/img-error.png"}
alt={news.title}
className="aspect-3/2 object-cover"
width={130}
height={86}
/>
<div className="flex-1">
<p className="text-[#363636] font-bold text-sm line-clamp-2">
{news.title}
</p>
<p className="text-gray-500 text-sm my-1">
{dayjs(news.release_at).format("DD/MM/YYYY")}
</p>
{/* <AppEditorContent className='line-clamp-2' value={news.description} /> */}
</div>
</Link>
);
}
export default CardNews;
......@@ -36,9 +36,8 @@ function PolicyAndLaws() {
<Link
key={item.id}
href={item.externalLink}
className={`group flex min-h-[58px] gap-3 rounded-[18px] px-4 py-3 transition-all duration-200 hover:bg-[#f5f7fb] hover:shadow-[0_10px_24px_rgba(36,70,156,0.08)] ${
index === 0 ? "pt-3.5" : ""
}`}
className={`group flex min-h-[58px] gap-3 rounded-[16px] pr-4 py-3 transition-all duration-200 hover:bg-[#f5f7fb] hover:shadow-[0_10px_24px_rgba(36,70,156,0.08)] ${index === 0 ? "pt-3.5" : ""
}`}
>
<span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500] transition-opacity duration-200 group-hover:opacity-0" />
......@@ -63,7 +62,7 @@ function PolicyAndLaws() {
) : (
<div
key={`policy-placeholder-${index}`}
className={`flex min-h-[58px] gap-3 rounded-[14px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`}
className={`flex min-h-[58px] gap-3 rounded-[16px] 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">
......
......@@ -19,7 +19,7 @@ function AdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: strin
href={item.link || FALLBACK_HREF}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
className="block overflow-hidden rounded-[16px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
title={item.name}
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
......@@ -45,7 +45,7 @@ function FallbackAdItem({ src }: { src: string }) {
href={FALLBACK_HREF}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
className="block overflow-hidden rounded-[16px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
title="Quảng cáo VCCI HCM"
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
......
"use client";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import partnerImages from "@/constants/partnerImages";
import { ChevronRight, Play } from "lucide-react";
import Link from "next/link";
......@@ -8,7 +8,7 @@ import { useGetOrganizations } from "@/api/vcci-hcm/endpoints/organizations";
import type { Organization } from "@/api/vcci-hcm/models";
import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video";
import type { Video } from "@/api/vcci-news/models/video";
import { MOCK_PARTNERS_RESPONSE } from "@/app/api/mock-data";
import { MOCK_PARTNERS_RESPONSE } from "@/mockdata/bff-fallback";
import { getVideoThumbnail, normalizeVideoUrl } from "@/lib/utils/video";
import { Autoplay } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
......@@ -67,10 +67,10 @@ const renderPartnerContent = (partners: Organization[]) => {
rel="noreferrer"
className="block"
>
<div 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)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_24px_rgba(31,59,124,0.1)] xl:h-[151px]">
<ImageNext
src={resolvePartnerImage(partner.avatar, index)}
alt={partner.name}
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_24px_rgba(31,59,124,0.1)] xl:h-[151px]">
<Image
src={resolvePartnerImage(partner.avatar, index) ?? "/img-error.png"}
alt={partner.name ?? ""}
width={140}
height={72}
className="max-h-full w-full object-contain"
......@@ -78,10 +78,10 @@ const renderPartnerContent = (partners: Organization[]) => {
</div>
</a>
) : (
<div 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-[151px]">
<ImageNext
src={resolvePartnerImage(partner.avatar, index)}
alt={partner.name}
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] xl:h-[151px]">
<Image
src={resolvePartnerImage(partner.avatar, index) ?? "/img-error.png"}
alt={partner.name ?? ""}
width={140}
height={72}
className="max-h-full w-full object-contain"
......@@ -95,7 +95,7 @@ const renderPartnerContent = (partners: Organization[]) => {
}
return (
<div className="rounded-[14px] border border-[#edf1f7] bg-white px-5 py-10 text-center text-sm text-gray-500">
<div className="rounded-[16px] border border-[#edf1f7] bg-white px-5 py-10 text-center text-sm text-gray-500">
Chưa có thông tin.
</div>
);
......@@ -179,7 +179,7 @@ function VideoAndPartners() {
className="overflow-hidden rounded-[16px] border border-[#e5ebf4] bg-white shadow-[0_10px_22px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_16px_30px_rgba(31,59,124,0.12)]"
>
<div className="group relative aspect-[1.95/1] overflow-hidden">
<ImageNext
<Image
src={video.thumbnail}
alt={video.name}
width={640}
......
......@@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
import { getApiV10Post, useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import Links from "@/links";
import { MOCK_HOME_POSTS } from "@lib/mock-home-posts";
import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
type RawHomeCategory = {
id?: string | null;
......
......@@ -22,7 +22,7 @@ export default function HomePage() {
<section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0">
<News />
<Advertisements count={2} startIndex={0} />
<Advertisements count={3} startIndex={0} />
</section >
<HorizontalAdBanner />
......
'use client';
import dayjs from "dayjs";
import ImageNext from "@/components/shared/image-next";
import AppEditorContent from "@/components/shared/editor-content";
import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
......
......@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
......@@ -155,7 +155,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
className="group grid items-center gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="relative overflow-hidden rounded-md bg-[#edf1f5] aspect-[25/15] sm:aspect-[5/3]">
<ImageNext
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
......@@ -108,7 +108,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
>
<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
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
"use client";
import parse from "html-react-parser";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { getDynamicPostBodyHtml } from "./data";
import type { DynamicPostContentSection, DynamicPostItem } from "./types";
......@@ -32,7 +32,7 @@ function StructuredImageSection({ section }: { section: DynamicPostContentSectio
key={`${section.id}-${image.id || image.url}-${item.position}`}
className="overflow-hidden rounded-[18px] bg-white"
>
<ImageNext
<Image
src={image.url}
alt={image.alt || image.name || "Hình ảnh bài viết"}
width={1200}
......
......@@ -499,15 +499,6 @@ export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) {
return Links.resolveImageUrl(value);
}
export function extractFirstImageFromHtml(html?: string | null): string {
if (!html) return "";
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["']/i;
const match = html.match(imgRegex);
return match?.[1]?.trim() ?? "";
}
export function getDynamicPostSeoImage(post: DynamicPostItem | null): string {
if (!post) return "/thumbnail.png";
......@@ -524,14 +515,6 @@ export function getDynamicPostSeoImage(post: DynamicPostItem | null): string {
}
}
for (const section of sections) {
const htmlImage = extractFirstImageFromHtml(section.content);
if (htmlImage) return Links.resolveImageUrl(htmlImage);
}
const htmlImage = extractFirstImageFromHtml(post.content) || extractFirstImageFromHtml(post.summary);
if (htmlImage) return Links.resolveImageUrl(htmlImage);
return "/thumbnail.png";
}
......
......@@ -6,8 +6,9 @@ import { ShieldCheck, Target, Zap } from "lucide-react";
import parse from "html-react-parser";
import Link from "next/link";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { buildDynamicPostHref, buildVisibleNewsFilters, stripHtml } from "../data";
import links from "@/links";
import StructuredPostContent from "../StructuredPostContent";
import type { DynamicPostItem } from "../types";
......@@ -121,10 +122,12 @@ export default function AboutVcciHcmPage({
externalLink: buildDynamicPostHref(item.external_link?.trim() || "#", item.id ? String(item.id) : ""),
publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""),
thumbnailUrl:
item.thumbnail?.url?.trim() ||
item.thumbnail?.path?.trim() ||
item.thumbnail?.original?.trim() ||
"/thumbnail.png",
links.resolveImageUrl(
item.thumbnail?.url?.trim() ||
item.thumbnail?.path?.trim() ||
item.thumbnail?.original?.trim() ||
"",
) || "/thumbnail.png",
thumbnailAlt: String(item.title ?? "").trim() || "Tin VCCI",
})),
},
......@@ -322,7 +325,7 @@ export default function AboutVcciHcmPage({
className="group overflow-hidden rounded-[22px] bg-white shadow-[0_18px_38px_rgba(28,52,120,0.16)] transition-transform hover:-translate-y-1"
>
<div className="relative aspect-[1.28] overflow-hidden">
<ImageNext
<Image
src={item.thumbnailUrl}
alt={item.thumbnailAlt}
width={720}
......
......@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { FileText, Globe2, Newspaper, TrendingUp } from "lucide-react";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import type { DynamicPostItem } from "../types";
type MarketProfilePageProps = {
......@@ -235,10 +235,10 @@ export default function MarketProfilePage({ post }: MarketProfilePageProps) {
<div className="mt-7 overflow-hidden rounded-[30px] border border-[#dce7f7] bg-white shadow-[0_18px_42px_rgba(17,24,39,0.06)]">
<div className="relative min-h-[280px] bg-[#f3f7ff] p-4 sm:p-5">
<div className="absolute inset-x-4 top-4 z-10 flex flex-wrap gap-2 sm:inset-x-6 sm:top-6">
{activeRegion.markets.map((item) => {
const hasDoc = item.href && item.href !== "#";
return (
<div className="absolute inset-x-4 top-4 z-10 flex flex-wrap gap-2 sm:inset-x-6 sm:top-6">
{activeRegion.markets.map((item) => {
const hasDoc = item.href && item.href !== "#";
return (
<a
key={`${activeRegion.key}-${item.name}`}
href={item.href}
......@@ -252,19 +252,19 @@ export default function MarketProfilePage({ post }: MarketProfilePageProps) {
<FileText className="h-3.5 w-3.5 text-[#2450b5]" />
)}
</a>
);
})}
</div>
<ImageNext
src={activeRegion.image}
alt={activeRegion.imageAlt}
width={1200}
height={900}
className="h-full min-h-[280px] w-full rounded-[24px] object-cover object-center transition-all duration-300"
/>
);
})}
</div>
<Image
src={activeRegion.image}
alt={activeRegion.imageAlt}
width={1200}
height={900}
className="h-full min-h-[280px] w-full rounded-[24px] object-cover object-center transition-all duration-300"
/>
</div>
</div>
</div>
<aside className="rounded-[28px] border border-[#e6eefb] bg-[#fbfcff] p-6 shadow-[0_18px_42px_rgba(17,24,39,0.05)] xl:sticky xl:top-24">
......
......@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Pagination } from "@components/base/pagination";
import { Button } from "@/components/ui/button";
......@@ -54,7 +54,7 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="overflow-hidden rounded-md bg-[#edf1f5]">
<ImageNext
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -3,7 +3,7 @@
import { Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Play } from "lucide-react";
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import { Pagination } from "@/components/base/pagination";
import { Spinner } from "@/components/ui/spinner";
import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video";
......@@ -106,7 +106,7 @@ function VideoPageContent() {
className="group overflow-hidden rounded-[18px] border border-[#e5ebf4] bg-white shadow-[0_12px_30px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_38px_rgba(31,59,124,0.14)]"
>
<div className="relative aspect-video overflow-hidden bg-[#edf1f5]">
<ImageNext
<Image
src={video.thumbnail}
alt={video.name}
width={900}
......
"use client";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { BaseConfigBranchItem } from "@/mockdata/base-config";
export function BranchCard({
branch,
current,
onSelect,
onDelete,
}: {
branch: BaseConfigBranchItem;
current: boolean;
onSelect: () => void;
onDelete: () => void;
}) {
return (
<div
className={`rounded-3xl border p-4 transition-all ${current
? "border-[#063e8e]/30 bg-[#eef5ff] shadow-[0_10px_24px_rgba(6,62,142,0.1)]"
: "border-[#063e8e]/10 bg-white"
}`}
>
<button type="button" onClick={onSelect} className="w-full text-left">
<div className="text-sm font-semibold text-[#163b73]">
{branch.branchName || "Chi nhánh mới"}
</div>
<div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-600">
{branch.address || "Chưa cập nhật địa chỉ"}
</div>
</button>
<div className="mt-4 flex items-center justify-between">
<div className="text-xs text-slate-500">
{branch.hotline || "Chưa có hotline"}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onDelete}
className="h-8 w-8 text-red-600 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}
"use client";
import { ImagePlus, Save } from "lucide-react";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { fieldClassName, type ConfigItemForm, type ConfigItemMode } from "./types";
export function ConfigItemDialog({
open,
mode,
form,
previewMedia,
saving,
title,
description,
onOpenChange,
onChange,
onPickImage,
onSubmit,
}: {
open: boolean;
mode: ConfigItemMode;
form: ConfigItemForm;
previewMedia: AdminMediaItem | null;
saving: boolean;
title: string;
description: string;
onOpenChange: (open: boolean) => void;
onChange: <K extends keyof ConfigItemForm>(
key: K,
value: ConfigItemForm[K],
) => void;
onPickImage: () => void;
onSubmit: () => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[88vh] max-w-xl flex-col overflow-hidden rounded-3xl border-[#063e8e]/15 bg-white p-0">
<DialogHeader>
<div className="border-b border-[#063e8e]/10 px-6 py-5">
<DialogTitle className="text-xl text-[#063e8e]">
{title}
</DialogTitle>
<DialogDescription className="mt-2 text-sm text-gray-600">
{description}
</DialogDescription>
</div>
</DialogHeader>
<div className="scrollbar min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-gray-700">Tên hiển thị</Label>
<Input
value={form.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder={
mode === "logo" ? "Nhập tên logo..." : "Nhập tên banner..."
}
className={fieldClassName}
/>
</div>
{mode === "banner" ? (
<div className="space-y-2">
<Label className="text-gray-700">
Thời gian hiển thị (giây)
</Label>
<Input
type="number"
min={1}
max={60}
value={form.displayTimeSeconds}
onChange={(event) =>
onChange(
"displayTimeSeconds",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
</div>
) : null}
{mode === "banner" ? (
<div className="space-y-2">
<Label className="text-gray-700">Thứ tự hiển thị</Label>
<Input
type="number"
min={1}
value={form.sortOrder}
onChange={(event) =>
onChange("sortOrder", Number(event.target.value || 1))
}
className={fieldClassName}
/>
</div>
) : null}
<div className="space-y-3">
<Label className="text-gray-700">Hình ảnh</Label>
<div className="overflow-hidden rounded-3xl border border-dashed border-[#063e8e]/20 bg-[#eef4ff]/60">
<div className="relative aspect-[16/9]">
{previewMedia ? (
<SafeNextImage
src={previewMedia.url}
alt={previewMedia.alt || previewMedia.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Chưa chọn hình ảnh
</div>
)}
</div>
</div>
<Button
type="button"
variant="outline"
onClick={onPickImage}
className="rounded-xl border-[#063e8e]/15 text-gray-700 hover:bg-[#edf4ff]"
>
<ImagePlus className="mr-2 h-4 w-4" />
Chọn từ thư viện
</Button>
</div>
{mode === "banner" ? (
<div className="flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-[#f7faff] px-4 py-3">
<div>
<div className="text-sm font-medium text-[#163b73]">
Trạng thái hiển thị
</div>
<div className="text-xs text-gray-500">
{form.isActive ? "Đang bật hiển thị" : "Đang tắt hiển thị"}
</div>
</div>
<Switch
checked={form.isActive}
onCheckedChange={(value) => onChange("isActive", value)}
/>
</div>
) : null}
</div>
</div>
<DialogFooter className="border-t border-[#063e8e]/10 px-6 py-4">
<div className="flex w-full justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-xl border-[#063e8e]/15 text-gray-700"
>
Hủy
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={saving}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Save className="mr-2 h-4 w-4" />
{saving ? "Đang lưu..." : "Lưu cấu hình"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
"use client";
import type { BaseConfigBannerItem } from "@/mockdata/base-config";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { SafeNextImage } from "@/components/admin/safe-next-image";
export function ConfigItemPreview({
title,
item,
media,
current,
onSelect,
}: {
title: string;
item: BaseConfigBannerItem;
media: AdminMediaItem | null;
current: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={`overflow-hidden rounded-3xl border text-left transition-all ${current
? "border-[#063e8e]/35 bg-[#edf4ff] shadow-[0_10px_24px_rgba(6,62,142,0.12)]"
: "border-[#063e8e]/10 bg-white hover:border-[#063e8e]/25 hover:shadow-sm"
}`}
>
<div className="relative aspect-[16/10] overflow-hidden bg-[#eef4ff]">
{media ? (
<SafeNextImage
src={media.url}
alt={media.alt || media.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Chưa chọn hình ảnh
</div>
)}
</div>
<div className="space-y-2 px-4 py-3">
<div className="line-clamp-1 text-sm font-semibold text-[#163b73]">
{title}
</div>
<div className="line-clamp-2 text-sm text-gray-600">{item.name}</div>
</div>
</button>
);
}
import type { Logo } from "@/api/vcci-news/models";
import type { AdminMediaItem } from "@/mockdata/admin-news";
export const fieldClassName =
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
export type ConfigItemMode = "logo" | "banner";
export type ApiEnvelope<T> = {
responseData?: T;
data?: {
responseData?: T;
};
};
export type LogoMediaItem = AdminMediaItem & {
logoId?: string;
};
export type LogoListResponse = {
rows?: Logo[];
};
export type PageEnvelope<T> = {
rows?: T[];
count?: number;
page?: number;
pageSize?: number;
};
export type ConfigItemForm = {
name: string;
imageId: string;
isActive: boolean;
displayTimeSeconds: number;
sortOrder: number;
};
import type {
Banner,
BannerMutate,
Logo,
SiteInformationBranch,
SiteInformationBranchMutate,
SiteInformationData,
SiteInformationSocialLink,
SiteInformationSocialMutate,
} from "@/api/vcci-news/models";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import links from "@/links";
import {
type BaseConfigBannerItem,
type BaseConfigBranchItem,
type BaseConfigData,
type BaseConfigLogoItem,
type BaseConfigSocialItem,
createBaseConfigItemId,
} from "@/mockdata/base-config";
import type {
ApiEnvelope,
ConfigItemForm,
LogoMediaItem,
} from "./types";
export function emptyItemForm(): ConfigItemForm {
return {
name: "",
imageId: "",
isActive: true,
displayTimeSeconds: 5,
sortOrder: 1,
};
}
export function resolveMediaItem(
mediaMap: Map<string, AdminMediaItem>,
imageId: string,
) {
return mediaMap.get(imageId) ?? null;
}
export function getEnvelopeData<T>(payload: unknown): T | undefined {
const root = payload as ApiEnvelope<T>;
return root.responseData ?? root.data?.responseData;
}
export function mapApiBranchToConfig(
branch: SiteInformationBranch,
): BaseConfigBranchItem {
return {
id: branch.id ?? createBaseConfigItemId("branch"),
branchName: branch.branch_name ?? "",
address: branch.address ?? "",
hotline: branch.hotline ?? branch.telephone ?? "",
email: branch.email ?? "",
fax: branch.fax ?? "",
mapsEmbedUrl: branch.googlemap_link ?? "",
sortOrder: branch.sort_order ?? 1,
isVisible: branch.is_active ?? true,
};
}
export function mapConfigBranchToApi(
branch: BaseConfigBranchItem,
index: number,
): SiteInformationBranchMutate {
return {
branch_name: branch.branchName.trim() || null,
address: branch.address.trim() || null,
hotline: branch.hotline.trim() || null,
email: branch.email.trim() || null,
fax: branch.fax.trim() || null,
googlemap_link: branch.mapsEmbedUrl.trim() || null,
sort_order: Number.isFinite(branch.sortOrder)
? branch.sortOrder
: index + 1,
is_active: branch.isVisible,
};
}
export function mapApiSocialToConfig(
social: SiteInformationSocialLink,
): BaseConfigSocialItem {
return {
id: social.id,
label: social.label,
url: social.url ?? "",
isVisible: social.is_active,
sortOrder: social.sort_order,
};
}
export function mapConfigSocialToApi(
social: BaseConfigSocialItem,
): SiteInformationSocialMutate {
return {
url: social.url.trim() || null,
sort_order: social.sortOrder,
is_active: social.isVisible,
};
}
export function mapApiBannerToConfig(banner: Banner): BaseConfigBannerItem {
return {
id: banner.id ?? createBaseConfigItemId("banner"),
name: banner.banner_name ?? "",
imageId: banner.file_id ?? "",
isActive: banner.status !== "INACTIVE",
displayTimeSeconds: banner.display_time ?? 5,
sortOrder: banner.display_order ?? 1,
};
}
export function mapConfigBannerToApi(banner: BaseConfigBannerItem): BannerMutate {
return {
banner_name: banner.name.trim(),
file_id: banner.imageId,
display_order: banner.sortOrder,
display_time: Math.max(1, banner.displayTimeSeconds || 1),
status: banner.isActive ? "ACTIVE" : "INACTIVE",
};
}
export function mapApiLogoToConfig(logo: Logo): {
logo: BaseConfigLogoItem | null;
media: LogoMediaItem | null;
} | null {
const media: LogoMediaItem = {
id: logo.file_id,
logoId: logo.id,
name: logo.logo_name,
alt: logo.logo_name,
url: links.resolveImageUrl(logo.logo_url) || "/img-error.png",
mime: "image/*",
size: 0,
created_at: logo.created_at,
updated_at: logo.updated_at,
source: "upload",
};
return {
logo: {
id: logo.id,
name: logo.logo_name,
imageId: logo.file_id,
isActive: true,
},
media,
};
}
export function applySiteInformationToConfig(
baseConfig: BaseConfigData,
siteInformation: SiteInformationData,
logo?: Logo | null,
): BaseConfigData {
const logoConfig = logo ? mapApiLogoToConfig(logo) : null;
return {
...baseConfig,
logo: logoConfig?.logo ?? baseConfig.logo,
websiteName: siteInformation.website_name ?? baseConfig.websiteName,
websiteLink: siteInformation.website_link ?? baseConfig.websiteLink,
branches: Array.isArray(siteInformation.branches)
? siteInformation.branches.map(mapApiBranchToConfig)
: baseConfig.branches,
socials: Array.isArray(siteInformation.socials)
? siteInformation.socials.map(mapApiSocialToConfig)
: baseConfig.socials,
};
}
This diff is collapsed.
"use client";
import {
TableCell,
TableRow,
} from "@/components/ui/table";
export function HeaderCategoryPostsLoading() {
return Array.from({ length: 3 }).map((_, index) => (
<TableRow
key={`loading-${index}`}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/[0.03]"}
>
<TableCell colSpan={6} className="px-4 py-4">
<div className="h-20 animate-pulse rounded-2xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
));
}
import dayjs from "dayjs";
import { buildHeaderCategoryTree } from "@/mockdata/header-config";
export const PAGE_SIZE = 10;
export type HeaderCategoryTreeNode = ReturnType<typeof buildHeaderCategoryTree>;
export function formatDateTime(value: string) {
return value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "—";
}
export function flattenTree(items: HeaderCategoryTreeNode) {
const rows: HeaderCategoryTreeNode = [];
const walk = (nodes: HeaderCategoryTreeNode) => {
nodes.forEach((item) => {
rows.push(item);
if (item.children.length > 0) {
walk(item.children);
}
});
};
walk(items);
return rows;
}
"use client";
import React from "react";
import dayjs from "dayjs";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
......@@ -36,41 +35,12 @@ import {
} from "@/lib/api/cms-admin";
import { ADMIN_NEWS_TYPE_LABELS } from "@/mockdata/admin-news";
import { buildHeaderCategoryTree } from "@/mockdata/header-config";
const PAGE_SIZE = 10;
function formatDateTime(value: string) {
return value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "—";
}
function flattenTree(items: ReturnType<typeof buildHeaderCategoryTree>) {
const rows: ReturnType<typeof buildHeaderCategoryTree> = [];
const walk = (nodes: ReturnType<typeof buildHeaderCategoryTree>) => {
nodes.forEach((item) => {
rows.push(item);
if (item.children.length > 0) {
walk(item.children);
}
});
};
walk(items);
return rows;
}
function HeaderCategoryPostsLoading() {
return Array.from({ length: 3 }).map((_, index) => (
<TableRow
key={`loading-${index}`}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/[0.03]"}
>
<TableCell colSpan={6} className="px-4 py-4">
<div className="h-20 animate-pulse rounded-2xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
));
}
import { HeaderCategoryPostsLoading } from "./_components/HeaderCategoryPostsLoading";
import {
PAGE_SIZE,
flattenTree,
formatDateTime,
} from "./_components/utils";
export default function HeaderCategoryPostsPage() {
const params = useParams();
......@@ -78,19 +48,19 @@ export default function HeaderCategoryPostsPage() {
const pathname = usePathname();
const searchParams = useSearchParams();
const categoryId = String(params.categoryId ?? "");
const [items, setItems] = React.useState<CmsNewsItem[]>([]);
const [headerItems, setHeaderItems] = React.useState<CmsHeaderCategoryItem[]>([]);
const [search, setSearch] = React.useState(() => searchParams.get("q") ?? "");
const [ready, setReady] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<CmsNewsItem | null>(null);
const didMountRef = React.useRef(false);
const [total, setTotal] = React.useState(0);
const [page, setPage] = React.useState(() => {
const [items, setItems] = useState<CmsNewsItem[]>([]);
const [headerItems, setHeaderItems] = useState<CmsHeaderCategoryItem[]>([]);
const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
const [ready, setReady] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CmsNewsItem | null>(null);
const didMountRef = useRef(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(() => {
const parsedPage = Number(searchParams.get("page") ?? 1);
return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1;
});
const listQueryString = React.useMemo(() => {
const listQueryString = useMemo(() => {
const nextParams = new URLSearchParams();
if (page > 1) {
......@@ -104,12 +74,12 @@ export default function HeaderCategoryPostsPage() {
return nextParams.toString();
}, [page, search]);
const listPath = React.useMemo(
const listPath = useMemo(
() => (listQueryString ? `${pathname}?${listQueryString}` : pathname),
[listQueryString, pathname],
);
React.useEffect(() => {
useEffect(() => {
let cancelled = false;
const load = async () => {
......@@ -151,11 +121,11 @@ export default function HeaderCategoryPostsPage() {
};
}, [categoryId, page, search]);
const flatCategories = React.useMemo(() => {
const flatCategories = useMemo(() => {
return flattenTree(buildHeaderCategoryTree(headerItems));
}, [headerItems]);
const category = React.useMemo(
const category = useMemo(
() => flatCategories.find((item) => item.id === categoryId) ?? null,
[categoryId, flatCategories],
);
......@@ -166,16 +136,16 @@ export default function HeaderCategoryPostsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const isSinglePostCategory = category?.type === "page";
const createHref = `/admin/header-config/${categoryId}/posts/new`;
const createHref = `/admin/news/new`;
React.useEffect(() => {
useEffect(() => {
if (!ready) return;
if (!category || !canManagePosts) {
router.replace("/admin/header-config");
}
}, [canManagePosts, category, ready, router]);
React.useEffect(() => {
useEffect(() => {
const nextPath = listQueryString ? `${pathname}?${listQueryString}` : pathname;
const currentPath = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
......@@ -184,7 +154,7 @@ export default function HeaderCategoryPostsPage() {
}
}, [listQueryString, pathname, router, searchParams]);
React.useEffect(() => {
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
......@@ -193,13 +163,13 @@ export default function HeaderCategoryPostsPage() {
setPage((currentPage) => (currentPage === 1 ? currentPage : 1));
}, [search]);
React.useEffect(() => {
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
const stats = React.useMemo(() => {
const stats = useMemo(() => {
return [
{
label: "Tổng bài viết",
......@@ -387,7 +357,7 @@ export default function HeaderCategoryPostsPage() {
label: "Chỉnh sửa bài viết",
onClick: () =>
router.push(
`/admin/header-config/${categoryId}/posts/${item.id}?returnTo=${encodeURIComponent(listPath)}`,
`/admin/news/${item.id}?returnTo=${encodeURIComponent(listPath)}`,
),
},
{
......
"use client";
import React from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { AdminNewsForm } from "@/components/admin/news-form";
import { fetchHeaderConfigItems, type CmsHeaderCategoryItem } from "@/lib/api/cms-admin";
export default function HeaderCategoryPostFormPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const categoryId = String(params.categoryId ?? "");
const postId = String(params.postId ?? "");
const returnTo = searchParams.get("returnTo");
const [category, setCategory] = React.useState<CmsHeaderCategoryItem | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const headerConfig = await fetchHeaderConfigItems();
if (cancelled) return;
setCategory(headerConfig.items.find((item) => item.id === categoryId) ?? null);
} catch (error) {
if (cancelled) return;
toast.error(error instanceof Error ? error.message : "Không thể tải danh mục");
} finally {
if (!cancelled) {
setReady(true);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [categoryId]);
React.useEffect(() => {
if (!ready) return;
if (!category || (category.type !== "page" && category.type !== "news")) {
router.replace("/admin/header-config");
}
}, [category, ready, router]);
if (!ready || !category || (category.type !== "page" && category.type !== "news")) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700 shadow-sm">
Đang tải form bài viết...
</div>
);
}
return (
<AdminNewsForm
newsId={postId}
presetHeaderCategoryId={category.id}
lockedType={category.type === "page" ? "baiviettrang" : "tintuc"}
returnPath={returnTo || `/admin/header-config/${category.id}/posts`}
/>
);
}
......@@ -220,39 +220,39 @@ export function HeaderCategoryTable({
actions={[
...(!isProtectedHomeCategory
? [
{
kind: "edit" as const,
label: "Chỉnh sửa danh mục",
onClick: () => onEdit(item),
},
]
{
kind: "edit" as const,
label: "Chỉnh sửa danh mục",
onClick: () => onEdit(item),
},
]
: []),
...(canManagePosts
? [
{
kind: "manage" as const,
label: "Quản lý bài viết",
href: `/admin/header-config/${item.id}/posts`,
},
]
{
kind: "manage" as const,
label: "Quản lý bài viết",
href: `/admin/header-config/${item.id}`,
},
]
: []),
...(canCreateChild
? [
{
kind: "create-child" as const,
label: "Thêm danh mục con",
onClick: () => onCreateChild(item),
},
]
{
kind: "create-child" as const,
label: "Thêm danh mục con",
onClick: () => onCreateChild(item),
},
]
: []),
...(!isProtectedHomeCategory
? [
{
kind: "delete" as const,
label: "Xóa danh mục",
onClick: () => onDelete(item),
},
]
{
kind: "delete" as const,
label: "Xóa danh mục",
onClick: () => onDelete(item),
},
]
: []),
]}
/>
......
import Image from "next/image";
import { LockKeyhole, ShieldCheck } from "lucide-react";
import { useGetApiV10Logo } from "@/api/vcci-news/endpoints/logo";
import type { Logo } from "@/api/vcci-news/models/logo";
export type AuthMode = "login" | "forgot";
export function AuthShell({
mode,
children,
}: {
mode: AuthMode;
children: React.ReactNode;
}) {
const { data: logoData } = useGetApiV10Logo(
{
page: 1,
pageSize: 1,
sortField: "updated_at",
sortOrder: "desc",
},
{
query: {
select: (response: any) => {
const responseData = response?.responseData ?? response?.data?.responseData;
return (responseData?.rows?.[0] as Logo | undefined) ?? null;
},
},
}
);
// Always use the static /logo.png from public/ for the login screen so the
// logo renders reliably regardless of backend logo/upload state.
const logoSrc = "/logo.png";
const title =
mode === "login"
? "Đăng nhập quản trị"
: "Khôi phục mật khẩu";
const description =
mode === "login"
? "Truy cập khu vực quản trị nội dung VCCI News."
: "Gửi yêu cầu reset mật khẩu cho ban quản trị.";
return (
<div className="min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-6xl items-center">
<div className="grid w-full overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[0_20px_60px_rgba(6,62,142,0.10)] lg:grid-cols-[0.95fr_1.05fr]">
<section className="relative hidden border-r border-[#063e8e]/10 bg-[#edf4ff] px-10 py-10 lg:block">
<div className="absolute inset-x-0 top-0 h-1 bg-[#063e8e]" />
<div className="flex h-full flex-col justify-between">
<div>
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-white shadow-sm">
<Image
src={logoSrc}
alt={logoData?.logo_name || "VCCI HCM"}
width={48}
height={48}
className="h-12 w-12 object-contain"
priority
/>
</div>
<div>
<div className="text-sm font-bold uppercase tracking-[0.2em] text-[#063e8e]">
{logoData?.logo_name || "VCCI News"}
</div>
<div className="mt-1 text-sm text-gray-700">
Trang quản trị website
</div>
</div>
</div>
<div className="mt-14 max-w-md">
<div className="inline-flex items-center gap-2 rounded-full border border-[#063e8e]/15 bg-white px-3 py-1.5 text-sm font-medium text-[#063e8e]">
<ShieldCheck className="h-4 w-4" />
Khu vực bảo mật
</div>
<h1 className="mt-6 text-4xl font-bold leading-tight text-gray-900">
Quản lý nội dung với giao diện riêng cho admin.
</h1>
<p className="mt-5 text-base leading-7 text-gray-700">
Hệ thống sử dụng tài khoản quản trị để bảo vệ cấu hình
website, bài viết, media và các dữ liệu vận hành.
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
{["Cấu hình", "Bài viết", "Liên hệ"].map((item) => (
<div
key={item}
className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3"
>
<div className="text-sm font-semibold text-[#063e8e]">
{item}
</div>
<div className="mt-1 h-1.5 rounded-full bg-[#dbe8ff]" />
</div>
))}
</div>
</div>
</section>
<section className="px-5 py-6 sm:px-8 lg:px-12 lg:py-12">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff]">
<Image
src={logoSrc}
alt={logoData?.logo_name || "VCCI HCM"}
width={36}
height={36}
className="h-9 w-9 object-contain"
priority
/>
</div>
<div>
<div className="text-sm font-bold uppercase tracking-[0.2em] text-[#063e8e]">
{logoData?.logo_name || "VCCI News"}
</div>
<div className="text-sm text-gray-700">
Trang quản trị website
</div>
</div>
</div>
<div className="mb-8">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]">
<LockKeyhole className="h-6 w-6" />
</div>
<h2 className="mt-5 text-2xl font-bold text-gray-900">
{title}
</h2>
<p className="mt-2 text-sm leading-6 text-gray-700">
{description}
</p>
</div>
{children}
</div>
</section>
</div>
</div>
</div>
);
}
This diff is collapsed.
export function MediaCardSkeleton() {
return (
<div className="overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[0_18px_45px_rgba(6,62,142,0.08)]">
<div className="aspect-square animate-pulse bg-[#063e8e]/8" />
<div className="space-y-3 p-4">
<div className="h-4 w-2/3 animate-pulse rounded-full bg-[#063e8e]/10" />
<div className="h-3 w-full animate-pulse rounded-full bg-[#063e8e]/10" />
<div className="h-3 w-1/2 animate-pulse rounded-full bg-[#063e8e]/10" />
</div>
</div>
);
}
"use client";
import { useEffect, useRef, useState } from "react";
import {
Image as ImageIcon,
Save,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
type MediaFormValues,
EMPTY_MEDIA_FORM,
inputClassName,
} from "./types";
import { formatFileSize } from "./utils";
interface MediaFormDialogProps {
open: boolean;
saving: boolean;
onOpenChange: (open: boolean) => void;
onSave: (data: MediaFormValues) => Promise<void>;
}
export function MediaFormDialog({
open,
saving,
onOpenChange,
onSave,
}: MediaFormDialogProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [form, setForm] = useState<MediaFormValues>(EMPTY_MEDIA_FORM);
useEffect(() => {
if (!open) return;
setForm(EMPTY_MEDIA_FORM);
}, [open]);
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const previewUrl = URL.createObjectURL(file);
const defaultName = file.name.replace(/\.[^.]+$/, "");
setForm((previous) => {
if (previous.previewUrl) {
URL.revokeObjectURL(previous.previewUrl);
}
return {
file,
name: previous.name || defaultName,
previewUrl,
};
});
event.target.value = "";
};
useEffect(() => {
return () => {
if (form.previewUrl) {
URL.revokeObjectURL(form.previewUrl);
}
};
}, [form.previewUrl]);
const handleSave = async () => {
if (!form.file) {
toast.error("Vui lòng chọn ảnh cần tải lên");
return;
}
await onSave({
...form,
name: form.name.trim() || form.file.name,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100dvh-32px)] w-[calc(100vw-32px)] max-w-4xl flex-col overflow-hidden rounded-[32px] border border-[#063e8e]/15 bg-white p-0 shadow-[0_26px_70px_rgba(15,23,42,0.24)]">
<DialogHeader className="shrink-0 border-b border-[#063e8e]/10 px-6 py-5 sm:px-7">
<DialogTitle className="text-xl font-semibold text-[#063e8e]">
Tải ảnh lên
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto lg:grid lg:grid-cols-[1.1fr_0.9fr]">
<div className="border-b border-[#063e8e]/10 bg-[linear-gradient(180deg,#f5f9ff_0%,#eef5ff_100%)] p-6 lg:border-b-0 lg:border-r lg:p-7">
<div className="space-y-4">
<div className="overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]">
<div className="relative aspect-[16/10] bg-[radial-gradient(circle_at_top,#d9e8ff_0%,#f7faff_58%,#ffffff_100%)]">
{form.previewUrl ? (
<SafeNextImage
src={form.previewUrl}
alt={form.name}
fill
className="object-contain p-4"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[#063e8e]/10 text-[#063e8e]">
<ImageIcon className="h-7 w-7" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-700">
Chưa có ảnh nào được chọn
</p>
<p className="text-xs text-slate-500">
Chọn ảnh từ máy tính để tải lên hệ thống
</p>
</div>
</div>
)}
</div>
</div>
<div className="rounded-[28px] border border-dashed border-[#063e8e]/20 bg-white/90 p-5">
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
/>
<div className="space-y-3">
<p className="text-sm font-semibold text-slate-700">
Tải ảnh từ máy tính
</p>
<p className="text-sm text-slate-500">
Hỗ trợ ảnh JPG, PNG, WEBP. Ảnh sẽ được lưu vào API /file/upload.
</p>
<Button
type="button"
variant="outline"
onClick={() => inputRef.current?.click()}
className="rounded-2xl border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#edf4ff]"
>
<Upload className="mr-2 h-4 w-4" />
Chọn ảnh
</Button>
</div>
</div>
</div>
</div>
<div className="p-6 lg:p-7">
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-sm font-medium text-slate-700">Tên ảnh</Label>
<Input
value={form.name}
onChange={(event) =>
setForm((previous) => ({ ...previous, name: event.target.value }))
}
placeholder="Nhập tên ảnh"
className={inputClassName}
/>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4 text-sm text-slate-500">
<div className="flex items-center gap-2">
<p className="text-xs uppercase tracking-[0.14em] text-slate-400">Dung lượng</p>
<p className="font-semibold text-slate-700">
{formatFileSize(form.file?.size)}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="shrink-0 border-t border-[#063e8e]/10 bg-[#f8fbff] px-6 py-4 sm:px-7">
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-2xl border-[#063e8e]/15 bg-white text-slate-600 hover:bg-slate-50"
disabled={saving}
>
<X className="mr-2 h-4 w-4" />
Hủy
</Button>
<Button
type="button"
onClick={handleSave}
className="rounded-2xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
disabled={saving}
>
<Save className="mr-2 h-4 w-4" />
{saving ? "Đang tải..." : "Tải ảnh lên"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export const PAGE_SIZE = 10;
export type MediaFormValues = {
file: File | null;
name: string;
previewUrl: string;
};
export const EMPTY_MEDIA_FORM: MediaFormValues = {
file: null,
name: "",
previewUrl: "",
};
export const inputClassName =
"rounded-2xl border-[#063e8e]/15 bg-white text-gray-700 shadow-sm placeholder:text-gray-400 focus-visible:ring-[#063e8e]/20";
import { type CmsFileItem } from "@/lib/utils/file";
export function resolveApiError(error: unknown, fallback: string) {
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as { response?: { status?: number; data?: { message?: string; error?: string } } };
const status = axiosError.response?.status;
const apiMessage = axiosError.response?.data?.message || axiosError.response?.data?.error;
if (status && apiMessage) {
return `[Lỗi ${status}] ${apiMessage}`;
}
if (status) {
return `[Lỗi ${status}] ${fallback}`;
}
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
}
export function formatFileSize(size?: number | null) {
if (!size) return "Ảnh hệ thống";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDate(value?: string | null) {
if (!value) return "-";
return new Date(value).toLocaleString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function getFileSize(item: CmsFileItem) {
const importInfo = item as CmsFileItem & {
size?: number | null;
file_size?: number | null;
import_info?: { size?: number; file_size?: number } | null;
};
return (
importInfo.size ??
importInfo.file_size ??
importInfo.import_info?.size ??
importInfo.import_info?.file_size ??
0
);
}
This diff is collapsed.
"use client";
import * as React from "react";
import { useEffect, useState } from "react";
import { ArrowLeft, Save, Upload, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
......@@ -61,14 +61,14 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
const router = useRouter();
const isNew = !memberId || memberId === "new";
const [form, setForm] = React.useState<MemberFormValues>(EMPTY_MEMBER_FORM);
const [fields, setFields] = React.useState<MemberField[]>([]);
const [regions, setRegions] = React.useState<MemberRegion[]>([]);
const [imagePickerOpen, setImagePickerOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [form, setForm] = useState<MemberFormValues>(EMPTY_MEMBER_FORM);
const [fields, setFields] = useState<MemberField[]>([]);
const [regions, setRegions] = useState<MemberRegion[]>([]);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [ready, setReady] = useState(false);
React.useEffect(() => {
useEffect(() => {
const allFields = readMemberFields();
const allRegions = readMemberRegions();
setFields(allFields);
......
import { AdminMemberForm } from "@/components/admin/member-form";
import { AdminMemberForm } from "./_components/member-form";
interface AdminMemberDetailPageProps {
params: Promise<{ id: string }>;
......
export const selectTriggerClassName =
"w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[200px]";
export const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
export const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { type MemberField, type MemberRegion } from "@/mockdata/members";
import {
selectContentClassName,
selectItemClassName,
selectTriggerClassName,
} from "./constants";
interface MemberFiltersProps {
fields: MemberField[];
regions: MemberRegion[];
fieldFilter: string;
regionFilter: string;
onFieldFilterChange: (value: string) => void;
onRegionFilterChange: (value: string) => void;
}
export function MemberFilters({
fields,
regions,
fieldFilter,
regionFilter,
onFieldFilterChange,
onRegionFilterChange,
}: MemberFiltersProps) {
return (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<Select value={fieldFilter} onValueChange={onFieldFilterChange}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Lĩnh vực" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả lĩnh vực
</SelectItem>
{fields.map((f) => (
<SelectItem key={f.id} value={f.id} className={selectItemClassName}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={regionFilter} onValueChange={onRegionFilterChange}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Khu vực" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả khu vực
</SelectItem>
{regions.map((r) => (
<SelectItem key={r.id} value={r.id} className={selectItemClassName}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
"use client";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import {
TableCell,
TableRow,
} from "@/components/ui/table";
import { type MemberItem } from "@/mockdata/members";
interface MemberRowProps {
item: MemberItem;
index: number;
fieldName: string | undefined;
regionName: string | undefined;
onEdit: () => void;
onDelete: () => void;
}
export function MemberRow({
item,
index,
fieldName,
regionName,
onEdit,
onDelete,
}: MemberRowProps) {
return (
<TableRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell className="px-4 py-3 text-sm font-medium text-gray-800">
<div className="space-y-1">
<div>{item.name}</div>
{item.is_featured ? (
<Badge
variant="outline"
className="border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]"
>
Hội viên tiêu biểu
</Badge>
) : null}
</div>
</TableCell>
<TableCell className="px-4 py-3 text-center">
{item.image ? (
<div className="mx-auto h-12 w-16 overflow-hidden rounded-lg border border-[#063e8e]/15">
<SafeNextImage
src={item.image.url}
alt={item.image.alt || item.name}
width={64}
height={48}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="mx-auto flex h-12 w-16 items-center justify-center rounded-lg border border-dashed border-[#063e8e]/20 bg-[#063e8e]/5 text-xs text-gray-400">
Chưa có
</div>
)}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{regionName ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{fieldName ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{item.phone && <div>{item.phone}</div>}
{item.email && (
<div className="truncate text-xs text-[#063e8e]">{item.email}</div>
)}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
<span className="line-clamp-2">{item.address || "—"}</span>
</TableCell>
<TableCell className="px-4 py-3 text-center">
<AdminRowActions
actions={[
{
kind: "edit",
label: "Chỉnh sửa hội viên",
onClick: onEdit,
},
{
kind: "delete",
label: "Xóa hội viên",
onClick: onDelete,
},
]}
/>
</TableCell>
</TableRow>
);
}
"use client";
import {
TableCell,
TableRow,
} from "@/components/ui/table";
export function MemberTableLoading() {
return Array.from({ length: 3 }).map((_, index) => (
<TableRow
key={`loading-${index}`}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell colSpan={7} className="px-4 py-4">
<div className="h-16 animate-pulse rounded-2xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
));
}
"use client";
import { useEffect, useState } from "react";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { type MemberField } from "@/mockdata/members";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface FieldFormDialogProps {
open: boolean;
initial: MemberField | null;
onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void;
}
export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFormDialogProps) {
const [name, setName] = useState("");
useEffect(() => {
if (open) setName(initial?.name ?? "");
}, [open, initial]);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Vui lòng nhập tên lĩnh vực");
return;
}
onSave({ id: initial?.id, name: trimmed });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa lĩnh vực" : "Thêm lĩnh vực mới"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label className="text-gray-700">
Tên lĩnh vực <span className="text-red-500">*</span>
</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên lĩnh vực..."
className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 text-gray-700"
onClick={() => onOpenChange(false)}
>
<X className="mr-2 h-4 w-4" />
Hủy
</Button>
<Button
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave}
>
<Save className="mr-2 h-4 w-4" />
Lưu
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
"use client";
import * as React from "react";
import { Plus, Save, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
......@@ -29,93 +20,22 @@ import {
persistMemberFields,
readMemberFields,
} from "@/mockdata/members";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface FieldFormDialogProps {
open: boolean;
initial: MemberField | null;
onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void;
}
function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFormDialogProps) {
const [name, setName] = React.useState("");
React.useEffect(() => {
if (open) setName(initial?.name ?? "");
}, [open, initial]);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Vui lòng nhập tên lĩnh vực");
return;
}
onSave({ id: initial?.id, name: trimmed });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa lĩnh vực" : "Thêm lĩnh vực mới"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label className="text-gray-700">
Tên lĩnh vực <span className="text-red-500">*</span>
</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên lĩnh vực..."
className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 text-gray-700"
onClick={() => onOpenChange(false)}
>
<X className="mr-2 h-4 w-4" />
Hủy
</Button>
<Button
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave}
>
<Save className="mr-2 h-4 w-4" />
Lưu
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
import { FieldFormDialog } from "./_components/field-form-dialog";
export default function AdminMemberFieldsPage() {
const [items, setItems] = React.useState<MemberField[]>([]);
const [search, setSearch] = React.useState("");
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editTarget, setEditTarget] = React.useState<MemberField | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MemberField | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
const [items, setItems] = useState<MemberField[]>([]);
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editTarget, setEditTarget] = useState<MemberField | null>(null);
const [deleteTarget, setDeleteTarget] = useState<MemberField | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
setItems(readMemberFields());
setReady(true);
}, []);
const filtered = React.useMemo(() => {
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => item.name.toLowerCase().includes(keyword));
......
"use client";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { Plus, Users } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
......@@ -35,46 +24,29 @@ import {
readMemberRegions,
readMembers,
} from "@/mockdata/members";
const selectTriggerClassName =
"w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[200px]";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function MemberTableLoading() {
return Array.from({ length: 3 }).map((_, index) => (
<TableRow
key={`loading-${index}`}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell colSpan={7} className="px-4 py-4">
<div className="h-16 animate-pulse rounded-2xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
));
}
import { MemberFilters } from "./_components/member-filters";
import { MemberRow } from "./_components/member-row";
import { MemberTableLoading } from "./_components/member-table-loading";
export default function AdminMembersPage() {
const router = useRouter();
const [items, setItems] = React.useState<MemberItem[]>([]);
const [fields, setFields] = React.useState<MemberField[]>([]);
const [regions, setRegions] = React.useState<MemberRegion[]>([]);
const [search, setSearch] = React.useState("");
const [fieldFilter, setFieldFilter] = React.useState("all");
const [regionFilter, setRegionFilter] = React.useState("all");
const [deleteTarget, setDeleteTarget] = React.useState<MemberItem | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
const [items, setItems] = useState<MemberItem[]>([]);
const [fields, setFields] = useState<MemberField[]>([]);
const [regions, setRegions] = useState<MemberRegion[]>([]);
const [search, setSearch] = useState("");
const [fieldFilter, setFieldFilter] = useState("all");
const [regionFilter, setRegionFilter] = useState("all");
const [deleteTarget, setDeleteTarget] = useState<MemberItem | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
setItems(readMembers());
setFields(readMemberFields());
setRegions(readMemberRegions());
setReady(true);
}, []);
const filtered = React.useMemo(() => {
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
return items.filter((item) => {
......@@ -91,7 +63,7 @@ export default function AdminMembersPage() {
});
}, [items, search, fieldFilter, regionFilter]);
const stats = React.useMemo(
const stats = useMemo(
() => [
{
label: "Tổng hội viên",
......@@ -112,12 +84,12 @@ export default function AdminMembersPage() {
[items, fields, regions],
);
const fieldMap = React.useMemo(
const fieldMap = useMemo(
() => Object.fromEntries(fields.map((f) => [f.id, f.name])),
[fields],
);
const regionMap = React.useMemo(
const regionMap = useMemo(
() => Object.fromEntries(regions.map((r) => [r.id, r.name])),
[regions],
);
......@@ -143,39 +115,14 @@ export default function AdminMembersPage() {
onSearchChange={setSearch}
onActionClick={() => router.push("/admin/members/new")}
filters={
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<Select value={fieldFilter} onValueChange={setFieldFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Lĩnh vực" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả lĩnh vực
</SelectItem>
{fields.map((f) => (
<SelectItem key={f.id} value={f.id} className={selectItemClassName}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={regionFilter} onValueChange={setRegionFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Khu vực" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả khu vực
</SelectItem>
{regions.map((r) => (
<SelectItem key={r.id} value={r.id} className={selectItemClassName}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MemberFilters
fields={fields}
regions={regions}
fieldFilter={fieldFilter}
regionFilter={regionFilter}
onFieldFilterChange={setFieldFilter}
onRegionFilterChange={setRegionFilter}
/>
}
>
<div className="overflow-x-auto">
......@@ -202,72 +149,15 @@ export default function AdminMembersPage() {
</TableRow>
) : (
filtered.map((item, index) => (
<TableRow
<MemberRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell className="px-4 py-3 text-sm font-medium text-gray-800">
<div className="space-y-1">
<div>{item.name}</div>
{item.is_featured ? (
<Badge
variant="outline"
className="border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]"
>
Hội viên tiêu biểu
</Badge>
) : null}
</div>
</TableCell>
<TableCell className="px-4 py-3 text-center">
{item.image ? (
<div className="mx-auto h-12 w-16 overflow-hidden rounded-lg border border-[#063e8e]/15">
<SafeNextImage
src={item.image.url}
alt={item.image.alt || item.name}
width={64}
height={48}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="mx-auto flex h-12 w-16 items-center justify-center rounded-lg border border-dashed border-[#063e8e]/20 bg-[#063e8e]/5 text-xs text-gray-400">
Chưa có
</div>
)}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{regionMap[item.region_id] ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{fieldMap[item.field_id] ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{item.phone && <div>{item.phone}</div>}
{item.email && (
<div className="truncate text-xs text-[#063e8e]">{item.email}</div>
)}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
<span className="line-clamp-2">{item.address || "—"}</span>
</TableCell>
<TableCell className="px-4 py-3 text-center">
<AdminRowActions
actions={[
{
kind: "edit",
label: "Chỉnh sửa hội viên",
onClick: () => router.push(`/admin/members/${item.id}`),
},
{
kind: "delete",
label: "Xóa hội viên",
onClick: () => setDeleteTarget(item),
},
]}
/>
</TableCell>
</TableRow>
item={item}
index={index}
fieldName={fieldMap[item.field_id]}
regionName={regionMap[item.region_id]}
onEdit={() => router.push(`/admin/members/${item.id}`)}
onDelete={() => setDeleteTarget(item)}
/>
))
)}
</TableBody>
......
"use client";
import { useEffect, useState } from "react";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { type MemberRegion } from "@/mockdata/members";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface RegionFormDialogProps {
open: boolean;
initial: MemberRegion | null;
onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void;
}
export function RegionFormDialog({ open, initial, onOpenChange, onSave }: RegionFormDialogProps) {
const [name, setName] = useState("");
useEffect(() => {
if (open) setName(initial?.name ?? "");
}, [open, initial]);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Vui lòng nhập tên khu vực");
return;
}
onSave({ id: initial?.id, name: trimmed });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa khu vực" : "Thêm khu vực mới"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label className="text-gray-700">
Tên khu vực <span className="text-red-500">*</span>
</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên khu vực..."
className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 text-gray-700"
onClick={() => onOpenChange(false)}
>
<X className="mr-2 h-4 w-4" />
Hủy
</Button>
<Button
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave}
>
<Save className="mr-2 h-4 w-4" />
Lưu
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
"use client";
import * as React from "react";
import { Plus, Save, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
......@@ -29,93 +20,22 @@ import {
persistMemberRegions,
readMemberRegions,
} from "@/mockdata/members";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface RegionFormDialogProps {
open: boolean;
initial: MemberRegion | null;
onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void;
}
function RegionFormDialog({ open, initial, onOpenChange, onSave }: RegionFormDialogProps) {
const [name, setName] = React.useState("");
React.useEffect(() => {
if (open) setName(initial?.name ?? "");
}, [open, initial]);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Vui lòng nhập tên khu vực");
return;
}
onSave({ id: initial?.id, name: trimmed });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa khu vực" : "Thêm khu vực mới"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label className="text-gray-700">
Tên khu vực <span className="text-red-500">*</span>
</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên khu vực..."
className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 text-gray-700"
onClick={() => onOpenChange(false)}
>
<X className="mr-2 h-4 w-4" />
Hủy
</Button>
<Button
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave}
>
<Save className="mr-2 h-4 w-4" />
Lưu
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
import { RegionFormDialog } from "./_components/region-form-dialog";
export default function AdminMemberRegionsPage() {
const [items, setItems] = React.useState<MemberRegion[]>([]);
const [search, setSearch] = React.useState("");
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editTarget, setEditTarget] = React.useState<MemberRegion | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MemberRegion | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
const [items, setItems] = useState<MemberRegion[]>([]);
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editTarget, setEditTarget] = useState<MemberRegion | null>(null);
const [deleteTarget, setDeleteTarget] = useState<MemberRegion | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
setItems(readMemberRegions());
setReady(true);
}, []);
const filtered = React.useMemo(() => {
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => item.name.toLowerCase().includes(keyword));
......
export const fieldClassName =
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
export const readOnlyFieldClassName =
"rounded-xl border-[#063e8e]/10 bg-[#063e8e]/[0.03] text-gray-700 placeholder:text-gray-700";
export const selectTriggerClassName =
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
export const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
export const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
export const SEARCH_TAG_VISIBLE_LIMIT = 20;
"use client";
import * as React from "react";
import dayjs from "dayjs";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export function EventDatesDatePicker({
value,
onChange,
}: {
value: string[];
onChange: (dates: string[]) => void;
}) {
const [popoverOpen, setPopoverOpen] = React.useState(false);
const selectedDates = React.useMemo(
() => value.map((d) => dayjs(d).toDate()).filter((d) => !Number.isNaN(d.getTime())),
[value],
);
const handleMultipleSelect = (dates: Date[] | undefined) => {
if (!dates) {
onChange([]);
return;
}
const newDates = Array.from(
new Set(dates.map((d) => dayjs(d).format("YYYY-MM-DD"))),
).sort();
onChange(newDates);
};
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Plus className="mr-2 h-4 w-4" />
Thêm ngày{value.length > 0 ? ` (${value.length})` : ""}
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit p-0" align="start">
<div className="p-4">
<div className="mb-3 flex items-center justify-between gap-2 border-b border-gray-100 pb-3">
<span className="text-base font-semibold text-[#063e8e]">
{value.length > 0
? `Đã chọn ${value.length} ngày`
: "Chọn các ngày cụ thể"}
</span>
{value.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-3 text-sm text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => onChange([])}
>
Xóa tất cả
</Button>
)}
</div>
<Calendar
mode="multiple"
selected={selectedDates}
onSelect={handleMultipleSelect}
className="w-full [--cell-size:3.5rem]"
classNames={{
root: "w-full",
month: "flex w-full flex-col gap-4",
month_caption: "flex h-12 w-full items-center justify-center px-2 text-xl font-bold text-[#063e8e]",
nav: "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
button_previous: "h-12 w-12 select-none p-0 text-[#063e8e] hover:bg-[#063e8e]/10 aria-disabled:opacity-50 [&>svg]:size-6",
button_next: "h-12 w-12 select-none p-0 text-[#063e8e] hover:bg-[#063e8e]/10 aria-disabled:opacity-50 [&>svg]:size-6",
weekday: "flex-1 select-none rounded-md text-sm font-semibold uppercase text-gray-400",
day: "group/day relative aspect-square h-full w-full select-none p-0 text-center text-lg",
today: "ring-2 ring-[#063e8e]/40 rounded-full bg-[#063e8e]/5 text-[#063e8e] font-semibold",
outside: "text-gray-300",
}}
/>
<div className="mt-3 flex items-center justify-between gap-4 border-t border-gray-100 pt-3">
<span className="text-sm text-gray-400">
Click ngày để chọn / bỏ chọn
</span>
<Button
type="button"
variant="default"
size="default"
className="bg-[#063e8e] hover:bg-[#063e8e]/90"
onClick={() => setPopoverOpen(false)}
>
Xong
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
import * as React from "react";
export function FormSection({
title,
description,
children,
}: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-5 shadow-sm">
<div className="mb-4">
<h2 className="text-base font-semibold text-[#063e8e]">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-gray-700">{description}</p>
) : null}
</div>
{children}
</div>
);
}
"use client";
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { formatHeaderCategoryOptionLabel, type HeaderCategoryOption } from "./utils";
export function HeaderCategoryCombobox({
value,
options,
disabled,
onChange,
}: {
value: string;
options: HeaderCategoryOption[];
disabled?: boolean;
onChange: (value: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const selectedOption = options.find((option) => option.id === value) ?? null;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"h-11 w-full justify-between rounded-xl border-[#063e8e]/15 bg-white px-4 font-normal text-gray-700 hover:bg-white hover:text-gray-700 focus-visible:ring-[#063e8e]/30",
!selectedOption && "text-gray-700",
)}
>
<span className="truncate text-left">
{selectedOption
? formatHeaderCategoryOptionLabel(selectedOption)
: "Chọn danh mục hiển thị"}
</span>
<ChevronsUpDown className="ml-3 h-4 w-4 shrink-0 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[var(--radix-popover-trigger-width)] border-[#063e8e]/15 bg-white p-0 text-gray-700"
>
<Command className="bg-white text-gray-700">
<CommandInput
placeholder="Tìm danh mục hiển thị"
className="text-gray-700 placeholder:text-gray-500"
/>
<CommandList className="max-h-72">
<CommandEmpty className="text-gray-700">
Không tìm thấy danh mục phù hợp
</CommandEmpty>
{options.map((option) => (
<CommandItem
key={option.id}
value={`${option.id} ${option.name} ${option.type}`}
onSelect={() => {
onChange(option.id);
setOpen(false);
}}
className="gap-3 px-3 py-2 text-gray-700 data-[selected=true]:bg-[#063e8e]/10 data-[selected=true]:text-[#063e8e]"
>
<Check
className={cn(
"h-4 w-4 text-[#063e8e]",
value === option.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">
{formatHeaderCategoryOptionLabel(option)}
</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
"use client";
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { fieldClassName } from "./constants";
import { formatHeaderCategoryOptionLabel, type HeaderCategoryOption } from "./utils";
export function HeaderCategoryMultiPicker({
values,
options,
disabled,
onChange,
}: {
values: string[];
options: HeaderCategoryOption[];
disabled?: boolean;
onChange: (values: string[]) => void;
}) {
const [search, setSearch] = React.useState("");
const selectedIds = React.useMemo(() => new Set(values), [values]);
const selectedOptions = React.useMemo(
() => options.filter((option) => selectedIds.has(option.id)),
[options, selectedIds],
);
const filteredOptions = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
const availableOptions = options.filter((option) => !selectedIds.has(option.id));
const matchedOptions = keyword
? availableOptions.filter((option) =>
option.name.toLowerCase().includes(keyword),
)
: availableOptions;
return [...selectedOptions, ...matchedOptions.slice(0, 20)];
}, [options, search, selectedIds, selectedOptions]);
const toggleValue = (id: string, checked: boolean) => {
onChange(checked ? [...values, id] : values.filter((item) => item !== id));
};
return (
<div className="space-y-2">
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="min-w-0 flex-1">
<Label className="mb-1.5 block text-gray-700">
Danh mục hiển thị <span className="text-red-600">*</span>
</Label>
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Tìm danh mục theo tên"
disabled={disabled}
className={fieldClassName}
/>
</div>
<div className="rounded-lg border border-[#063e8e]/10 bg-white px-3 py-2 text-sm text-gray-700">
Đã chọn {values.length} danh mục
</div>
</div>
<div className="max-h-64 overflow-y-auto rounded-xl border border-[#063e8e]/10 bg-white p-2">
{filteredOptions.length > 0 ? (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{filteredOptions.map((option) => (
<label
key={option.id}
className="flex items-center gap-3 rounded-lg border border-[#063e8e]/10 bg-white px-3 py-2"
>
<Checkbox
checked={selectedIds.has(option.id)}
disabled={disabled}
onCheckedChange={(checked) => toggleValue(option.id, checked === true)}
className="border-[#063e8e]/30 data-[state=checked]:border-[#063e8e] data-[state=checked]:bg-[#063e8e]"
/>
<span className="min-w-0 truncate text-sm text-gray-700">
{formatHeaderCategoryOptionLabel(option)}
</span>
</label>
))}
</div>
) : (
<p className="px-3 py-2 text-sm text-gray-700">
{"Kh\u00f4ng t\u00ecm th\u1ea5y danh m\u1ee5c ph\u00f9 h\u1ee3p."}
</p>
)}
</div>
</div>
);
}
"use client";
import { Label } from "@/components/ui/label";
import { HeaderCategoryCombobox } from "./header-category-combobox";
import type { HeaderCategoryOption } from "./utils";
export function HeaderCategorySinglePicker({
value,
options,
disabled,
onChange,
}: {
value: string;
options: HeaderCategoryOption[];
disabled?: boolean;
onChange: (value: string) => void;
}) {
return (
<div className="space-y-2">
<Label className="mb-1.5 block text-gray-700">
Danh mục hiển thị <span className="text-red-600">*</span>
</Label>
<HeaderCategoryCombobox
value={value}
onChange={onChange}
disabled={disabled}
options={options}
/>
</div>
);
}
export function NewsFormLoadingState() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 animate-pulse rounded-xl bg-[#063e8e]/10" />
</div>
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="rounded-2xl border border-[#063e8e]/15 bg-white p-5 shadow-sm"
>
<div className="mb-4 space-y-2">
<div className="h-5 w-48 animate-pulse rounded bg-[#063e8e]/10" />
<div className="h-4 w-72 animate-pulse rounded bg-[#063e8e]/[0.05]" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="h-11 animate-pulse rounded-xl bg-[#063e8e]/[0.05]" />
<div className="h-11 animate-pulse rounded-xl bg-[#063e8e]/[0.05]" />
<div className="h-11 animate-pulse rounded-xl bg-[#063e8e]/[0.05] md:col-span-2" />
<div className="h-40 animate-pulse rounded-2xl bg-[#063e8e]/[0.05] md:col-span-2" />
</div>
</div>
))}
</div>
);
}
"use client";
import * as React from "react";
import { type ComponentType, useMemo, useState } from "react";
import { Clock, ChevronDown, ChevronUp, User, Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { useGetApiV10PostIdHistory } from "@/api/vcci-news/endpoints/post";
import type { PostHistoryItem, PostHistoryAction } from "@/api/vcci-news/types/post-history";
......@@ -10,7 +10,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
const ACTION_CONFIG: Record<
PostHistoryAction,
{ label: string; icon: React.ComponentType<{ className?: string }>; className: string }
{ label: string; icon: ComponentType<{ className?: string }>; className: string }
> = {
CREATE: {
label: "Tạo bài viết",
......@@ -73,7 +73,7 @@ function formatValue(value: unknown): string {
function HistoryItem({ item }: { item: PostHistoryItem }) {
const config = ACTION_CONFIG[item.action];
const Icon = config.icon;
const [isOpen, setIsOpen] = React.useState(item.action === "CREATE");
const [isOpen, setIsOpen] = useState(item.action === "CREATE");
return (
<div className="rounded-lg border border-gray-200 bg-white">
......@@ -181,13 +181,13 @@ interface PostHistoryViewerProps {
}
export function PostHistoryViewer({ postId }: PostHistoryViewerProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [isOpen, setIsOpen] = useState(false);
// Always fetch history so the count is correct even before the user opens
// the panel. Previously `enabled: isOpen` caused the count to show "0 thay
// đổi" until the panel was opened.
const { data, isLoading, isError, error } = useGetApiV10PostIdHistory(postId);
const historyItems = React.useMemo(() => {
const historyItems = useMemo(() => {
// useQuery's `data` IS the API response body: { responseData: [...], ... }
const raw = data as unknown as { responseData?: PostHistoryItem[]; data?: PostHistoryItem[] } | undefined;
if (!raw) return [];
......
import type { AdminMediaItem, AdminNewsImageRef, AdminNewsType } from "@/mockdata/admin-news";
import type { HeaderCategoryItem, HeaderCategoryTreeItem } from "@/mockdata/header-config";
export type HeaderCategoryOption = {
id: string;
name: string;
type: HeaderCategoryItem["type"];
depth: number;
};
export function flattenHeaderTree(
items: HeaderCategoryTreeItem[],
depth = 0,
): HeaderCategoryOption[] {
return items.flatMap((item) => [
{ id: item.id, name: item.name, type: item.type, depth },
...flattenHeaderTree(item.children, depth + 1),
]);
}
export function isCategoryCompatible(
headerType: HeaderCategoryItem["type"],
postType: AdminNewsType | "",
) {
if (!postType) return true;
if (postType === "tintuc") return headerType === "news";
if (postType === "baiviettrang") return headerType === "page";
return false;
}
export function toImageRef(item: AdminMediaItem): AdminNewsImageRef {
return {
id: item.id,
name: item.name,
alt: item.alt,
url: item.url,
};
}
export function formatHeaderCategoryOptionLabel(option: {
name: string;
depth: number;
}) {
return `${"-- ".repeat(option.depth)}${option.name}`;
}
export function isUuid(value: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}
import { AdminNewsForm } from "@/components/admin/news-form";
import { Suspense } from "react";
import { AdminNewsFormContent } from "./_components/admin-news-form-content";
interface AdminNewsDetailPageProps {
params: Promise<{
id: string;
}>;
searchParams: Promise<{
returnTo?: string;
}>;
}
export default async function AdminNewsDetailPage({
params,
searchParams,
}: AdminNewsDetailPageProps) {
const { id } = await params;
const { returnTo } = await searchParams;
return <AdminNewsForm newsId={id} returnPath={returnTo || "/admin/news"} />;
export default function AdminNewsDetailPage() {
return (
<Suspense
fallback={
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700">
Đang tải...
</div>
}
>
<AdminNewsFormContent />
</Suspense>
);
}
import {
TableCell,
TableRow,
} from "@/components/ui/table";
export function AdminNewsTableLoading() {
return Array.from({ length: 3 }).map((_, index) => (
<TableRow
key={`loading-${index}`}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/[0.03]"}
>
<TableCell colSpan={7} className="px-4 py-4">
<div className="h-20 animate-pulse rounded-2xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
));
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
"use client";
import Link from "next/link";
import { usePermission } from "@/hooks/usePermission";
import { NoPermissionMessage } from "@/components/shared/permission-gate";
export default function NoPermissionPage() {
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import type { CmsTagItem } from "@/lib/api/cms-admin";
export interface TagFormValues {
id?: string;
name: string;
slug: string;
}
export const PAGE_SIZE = 10;
export const EMPTY_FORM: TagFormValues = {
name: "",
slug: "",
};
export type { CmsTagItem };
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment