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 = { ...@@ -27,6 +27,18 @@ const nextConfig: NextConfig = {
port: "", port: "",
pathname: "/images/**", pathname: "/images/**",
}, },
{
protocol: "https",
hostname: "img.youtube.com",
port: "",
pathname: "/vi/**",
},
{
protocol: "https",
hostname: "news.vccihcm.vn",
port: "",
pathname: "/uploads/**",
},
], ],
}, },
}; };
......
"use client"; "use client";
import ImageNext from "@/components/shared/image-next"; import Image from "next/image";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay } from "swiper/modules"; import { Autoplay } from "swiper/modules";
import { Swiper as SwiperType } from "swiper/types"; import { Swiper as SwiperType } from "swiper/types";
...@@ -84,13 +84,18 @@ function BannerSlideItem({ ...@@ -84,13 +84,18 @@ function BannerSlideItem({
: "/thumbnail.png"; : "/thumbnail.png";
return ( return (
<ImageNext <Image
src={url} src={url}
alt={alt} alt={alt}
width={2560} width={2560}
height={720} height={720}
sizes="100vw" 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" 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,8 +36,7 @@ function BusinessOpportunities() { ...@@ -36,8 +36,7 @@ function BusinessOpportunities() {
<Link <Link
key={item.id} key={item.id}
href={item.externalLink} 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)] ${ 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" : ""
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" /> <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() { ...@@ -63,7 +62,7 @@ function BusinessOpportunities() {
) : ( ) : (
<div <div
key={`business-placeholder-${index}`} 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" /> <span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
......
...@@ -132,7 +132,7 @@ function EventsCalendar({ ...@@ -132,7 +132,7 @@ function EventsCalendar({
return ( return (
<aside <aside
className={cn( 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", compact ? "p-4" : "p-4 md:p-5",
className ?? "xl:w-[28%] xl:min-w-[320px]", className ?? "xl:w-[28%] xl:min-w-[320px]",
)} )}
...@@ -181,9 +181,9 @@ function EventsCalendar({ ...@@ -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-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 ? ( {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... Đang tải dữ liệu tháng này...
</div> </div>
) : null} ) : null}
...@@ -252,7 +252,7 @@ function EventsCalendar({ ...@@ -252,7 +252,7 @@ function EventsCalendar({
side="top" side="top"
align="center" align="center"
sideOffset={12} 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"> <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]"> <p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#7f8eab]">
...@@ -268,7 +268,7 @@ function EventsCalendar({ ...@@ -268,7 +268,7 @@ function EventsCalendar({
<Link <Link
key={item.id} key={item.id}
href={item.externalLink || "#"} 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"> <div className="flex items-start gap-2.5">
<span <span
...@@ -327,7 +327,7 @@ function EventsCalendar({ ...@@ -327,7 +327,7 @@ function EventsCalendar({
</div> </div>
{highlightedEvents.length > 0 ? ( {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 ? ( {selectedEvents.length > 1 ? (
<div className="mb-3 flex items-center justify-between gap-3 border-b border-[#e5edf8] pb-2.5"> <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]"> <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'; '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 { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import { ChevronRight } from "lucide-react";
import { getFallbackImage } from "@/lib/utils/fallback-image"; import { getFallbackImage } from "@/lib/utils/fallback-image";
function Events() { function Events() {
...@@ -23,8 +24,8 @@ function Events() { ...@@ -23,8 +24,8 @@ function Events() {
); );
return ( return (
<div className="flex-1 rounded-[28px] bg-linear-to-br from-[#14488f] to-[#2d67bf] p-4 text-white shadow-[0_18px_38px_rgba(16,61,130,0.24)] md:p-5"> <div className="flex-1 rounded-[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-start justify-between gap-4"> <div className="mb-4 flex items-center justify-between gap-4">
<div> <div>
<h2 className="client-section-title uppercase text-white"> <h2 className="client-section-title uppercase text-white">
Sự kiện sắp diễn ra Sự kiện sắp diễn ra
...@@ -34,9 +35,9 @@ function Events() { ...@@ -34,9 +35,9 @@ function Events() {
<Link <Link
href={eventsLink} 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> </Link>
</div> </div>
...@@ -44,10 +45,10 @@ function Events() { ...@@ -44,10 +45,10 @@ function Events() {
{featuredEvent ? ( {featuredEvent ? (
<Link <Link
href={featuredEvent.externalLink} 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]"> <div className="relative h-[180px] overflow-hidden md:h-[220px] xl:h-[248px]">
<ImageNext <Image
src={featuredEvent.thumbnail?.url ?? featuredFallback} src={featuredEvent.thumbnail?.url ?? featuredFallback}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title} alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720} width={720}
...@@ -95,7 +96,7 @@ function Events() { ...@@ -95,7 +96,7 @@ function Events() {
</div> </div>
</Link> </Link>
) : ( ) : (
<div className="flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.12)]"> <div className="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="h-[180px] bg-[#d7e3f9] md:h-[220px] xl:h-[248px]" />
<div className="space-y-2 p-3 pt-2.5"> <div className="space-y-2 p-3 pt-2.5">
<div className="h-6 w-5/6 rounded bg-[#e7eefb]" /> <div className="h-6 w-5/6 rounded bg-[#e7eefb]" />
...@@ -110,10 +111,10 @@ function Events() { ...@@ -110,10 +111,10 @@ function Events() {
<Link <Link
key={item.id} key={item.id}
href={item.externalLink} 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]"> <div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[14px]">
<ImageNext <Image
src={item.thumbnail?.url ?? sideFallbacks[index]} src={item.thumbnail?.url ?? sideFallbacks[index]}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
width={160} width={160}
...@@ -170,9 +171,9 @@ function Events() { ...@@ -170,9 +171,9 @@ function Events() {
) : ( ) : (
<div <div
key={`event-placeholder-${index}`} 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="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-white/25" /> <div className="h-5 w-5/6 rounded bg-white/25" />
<div className="mt-2 h-3 w-20 rounded bg-white/20" /> <div className="mt-2 h-3 w-20 rounded bg-white/20" />
......
'use client'; '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 { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
...@@ -28,7 +28,7 @@ function FeaturedNews() { ...@@ -28,7 +28,7 @@ function FeaturedNews() {
return ( return (
<section className="py-8 md:py-10"> <section className="py-8 md:py-10">
<div className="w-full"> <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> <div>
<h2 className="client-section-title uppercase text-[#24469c]"> <h2 className="client-section-title uppercase text-[#24469c]">
Tin nổi bật Tin nổi bật
...@@ -38,10 +38,9 @@ function FeaturedNews() { ...@@ -38,10 +38,9 @@ function FeaturedNews() {
<Link <Link
href={featuredOverviewLink} 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-5 w-5" />
<ChevronRight className="h-4 w-4" />
</Link> </Link>
</div> </div>
...@@ -49,10 +48,10 @@ function FeaturedNews() { ...@@ -49,10 +48,10 @@ function FeaturedNews() {
{primaryItem ? ( {primaryItem ? (
<Link <Link
href={primaryItem.externalLink} 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]"> <div className="relative h-full min-h-[195px] md:min-h-[320px] lg:min-h-[380px]">
<ImageNext <Image
src={primaryItem.thumbnail?.url ?? primaryFallback} src={primaryItem.thumbnail?.url ?? primaryFallback}
alt={primaryItem.thumbnail?.alt || primaryItem.title} alt={primaryItem.thumbnail?.alt || primaryItem.title}
width={1200} width={1200}
...@@ -79,7 +78,7 @@ function FeaturedNews() { ...@@ -79,7 +78,7 @@ function FeaturedNews() {
</div> </div>
</Link> </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]"> <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" /> <span className="mb-2 h-8 w-28 rounded-[10px] bg-white/80" />
<div className="h-8 w-3/4 rounded bg-white/90 md:h-10" /> <div className="h-8 w-3/4 rounded bg-white/90 md:h-10" />
...@@ -95,10 +94,10 @@ function FeaturedNews() { ...@@ -95,10 +94,10 @@ function FeaturedNews() {
<Link <Link
key={item.id} key={item.id}
href={item.externalLink} 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]"> <div className="relative flex h-full min-h-[165px]">
<ImageNext <Image
src={item.thumbnail?.url ?? secondaryFallbacks[index]} src={item.thumbnail?.url ?? secondaryFallbacks[index]}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
width={600} width={600}
...@@ -127,7 +126,7 @@ function FeaturedNews() { ...@@ -127,7 +126,7 @@ function FeaturedNews() {
) : ( ) : (
<div <div
key={`featured-placeholder-${index}`} 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"> <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" /> <span className="mb-2 h-7 w-24 rounded-[10px] bg-white/80" />
......
...@@ -32,7 +32,7 @@ function HorizontalAdBanner() { ...@@ -32,7 +32,7 @@ function HorizontalAdBanner() {
href={href} href={href}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" }} style={{ aspectRatio: "1600 / 200" }}
title={title} title={title}
> >
...@@ -40,6 +40,7 @@ function HorizontalAdBanner() { ...@@ -40,6 +40,7 @@ function HorizontalAdBanner() {
src={src} src={src}
alt={alt} alt={alt}
fill fill
sizes="100vw"
className="object-cover" className="object-cover"
unoptimized={isGif} unoptimized={isGif}
onError={() => { onError={() => {
......
'use client'; 'use client';
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts"; 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 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 { useGetOrganizations } from "@/api/vcci-hcm/endpoints/organizations";
import type { Organization } from "@/api/vcci-hcm/models"; import type { Organization } from "@/api/vcci-hcm/models";
import Link from "next/link"; import Link from "next/link";
...@@ -70,7 +71,7 @@ function Members() { ...@@ -70,7 +71,7 @@ function Members() {
const renderMemberContent = () => { const renderMemberContent = () => {
if (featuredMembersLoading) { if (featuredMembersLoading) {
return ( 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... Đang tải dữ liệu...
</div> </div>
); );
...@@ -78,7 +79,7 @@ function Members() { ...@@ -78,7 +79,7 @@ function Members() {
if (displayMembers.length === 0) { if (displayMembers.length === 0) {
return ( 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. Chưa có thông tin.
</div> </div>
); );
...@@ -102,7 +103,7 @@ function Members() { ...@@ -102,7 +103,7 @@ function Members() {
key={member.id} key={member.id}
className="!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]" 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 ? ( {detailUrl ? (
<a <a
href={detailUrl} href={detailUrl}
...@@ -112,9 +113,9 @@ function Members() { ...@@ -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-[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"> <div className="flex h-full w-full max-w-[260px] items-center justify-center">
<ImageNext <Image
src={resolveMemberImage(member.avatar, index)} src={resolveMemberImage(member.avatar, index) ?? "/img-error.png"}
alt={member.name} alt={member.name ?? ""}
width={260} width={260}
height={180} height={180}
className="h-[180px] w-[260px] max-w-full object-contain" className="h-[180px] w-[260px] max-w-full object-contain"
...@@ -129,9 +130,9 @@ function Members() { ...@@ -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-[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"> <div className="flex h-full w-full max-w-[260px] items-center justify-center">
<ImageNext <Image
src={resolveMemberImage(member.avatar, index)} src={resolveMemberImage(member.avatar, index) ?? "/img-error.png"}
alt={member.name} alt={member.name ?? ""}
width={260} width={260}
height={180} height={180}
className="h-[180px] w-[260px] max-w-full object-contain" className="h-[180px] w-[260px] max-w-full object-contain"
...@@ -153,8 +154,8 @@ function Members() { ...@@ -153,8 +154,8 @@ function Members() {
return ( return (
<section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch"> <section className="flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch">
<aside className="flex-1 rounded-[22px] bg-[#f7b500] p-4 shadow-[0_18px_34px_rgba(247,181,0,0.18)] md:p-5"> <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-start justify-between gap-3"> <div className="flex items-center justify-between gap-3 pb-10">
<div> <div>
<h2 className="client-section-title uppercase text-[#20449a]"> <h2 className="client-section-title uppercase text-[#20449a]">
Hội viên tiêu biểu Hội viên tiêu biểu
...@@ -166,19 +167,17 @@ function Members() { ...@@ -166,19 +167,17 @@ function Members() {
href={FEATURED_MEMBER_MORE_URL} href={FEATURED_MEMBER_MORE_URL}
target="_blank" target="_blank"
rel="noreferrer" 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> </Link>
</div> </div>
<div className="mt-4 border-t border-[#e7aa00] pt-5" />
{renderMemberContent()} {renderMemberContent()}
</aside> </aside>
<aside className="w-full xl:w-[31%] xl:min-w-[320px]"> <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> <div>
<h2 className="client-section-title uppercase text-[#24469c]"> <h2 className="client-section-title uppercase text-[#24469c]">
Kết nối hội viên Kết nối hội viên
...@@ -188,9 +187,9 @@ function Members() { ...@@ -188,9 +187,9 @@ function Members() {
<Link <Link
href={sectionLink} 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> </Link>
</div> </div>
...@@ -204,16 +203,16 @@ function Members() { ...@@ -204,16 +203,16 @@ function Members() {
autoplay={{ delay: 4000, disableOnInteraction: false }} autoplay={{ delay: 4000, disableOnInteraction: false }}
loop={connectionPosts.length > 1} loop={connectionPosts.length > 1}
slidesPerView={1} slidesPerView={1}
className="w-full overflow-hidden rounded-[20px]" className="w-full overflow-hidden rounded-[14px]"
> >
{connectionPosts.map((item) => ( {connectionPosts.map((item) => (
<SwiperSlide key={item.id}> <SwiperSlide key={item.id}>
<Link <Link
href={item.externalLink} 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]"> <div className="aspect-[16/10] overflow-hidden xl:aspect-[1.25/1]">
<ImageNext <Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE} src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
width={520} width={520}
...@@ -252,10 +251,10 @@ function Members() { ...@@ -252,10 +251,10 @@ function Members() {
<Link <Link
key={item.id} key={item.id}
href={item.externalLink} 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"> <div className="aspect-[16/10] overflow-hidden">
<ImageNext <Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE} src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
width={520} width={520}
...@@ -287,7 +286,7 @@ function Members() { ...@@ -287,7 +286,7 @@ function Members() {
</div> </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. Chưa có thông tin.
</div> </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'; '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 { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Link from "next/link"; import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
const tabs = [ const tabs = [
...@@ -37,7 +38,7 @@ function News() { ...@@ -37,7 +38,7 @@ function News() {
return ( return (
<div className="flex-1"> <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> <div>
<h2 className="client-section-title uppercase text-[#24469c]"> <h2 className="client-section-title uppercase text-[#24469c]">
Tin tức Tin tức
...@@ -65,9 +66,9 @@ function News() { ...@@ -65,9 +66,9 @@ function News() {
})} })}
<Link <Link
href={overviewLink} 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> </Link>
</div> </div>
</div> </div>
...@@ -79,10 +80,10 @@ function News() { ...@@ -79,10 +80,10 @@ function News() {
{featuredArticle ? ( {featuredArticle ? (
<Link <Link
href={featuredArticle.externalLink} 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"> <div className="relative aspect-[1.4/1] overflow-hidden">
<ImageNext <Image
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"} src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title} alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720} width={720}
...@@ -128,7 +129,7 @@ function News() { ...@@ -128,7 +129,7 @@ function News() {
</div> </div>
</Link> </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="aspect-[1.75/1] bg-[#eef3fb]" />
<div className="space-y-2 p-3"> <div className="space-y-2 p-3">
<div className="h-5 w-24 rounded bg-[#eef3fb]" /> <div className="h-5 w-24 rounded bg-[#eef3fb]" />
...@@ -147,10 +148,10 @@ function News() { ...@@ -147,10 +148,10 @@ function News() {
<Link <Link
key={news.id} key={news.id}
href={news.externalLink} 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"> <div className="h-20 w-20 shrink-0 overflow-hidden rounded-[14px]">
<ImageNext <Image
src={news.thumbnail?.url ?? "/thumbnail.png"} src={news.thumbnail?.url ?? "/thumbnail.png"}
alt={news.thumbnail?.alt || news.title} alt={news.thumbnail?.alt || news.title}
width={160} width={160}
...@@ -203,9 +204,9 @@ function News() { ...@@ -203,9 +204,9 @@ function News() {
) : ( ) : (
<div <div
key={`news-placeholder-${index}`} 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="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-[#eef3fb]" /> <div className="h-5 w-5/6 rounded bg-[#eef3fb]" />
<div className="mt-1 h-4 w-24 rounded bg-[#f4f7fb]" /> <div className="mt-1 h-4 w-24 rounded bg-[#f4f7fb]" />
...@@ -223,10 +224,10 @@ function News() { ...@@ -223,10 +224,10 @@ function News() {
{featuredArticle ? ( {featuredArticle ? (
<Link <Link
href={featuredArticle.externalLink} 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"> <div className="relative aspect-[16/9] overflow-hidden">
<ImageNext <Image
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"} src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title} alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720} width={720}
...@@ -272,7 +273,7 @@ function News() { ...@@ -272,7 +273,7 @@ function News() {
</div> </div>
</Link> </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="aspect-[16/9] bg-[#eef3fb]" />
<div className="space-y-2 p-3"> <div className="space-y-2 p-3">
<div className="h-5 w-24 rounded bg-[#eef3fb]" /> <div className="h-5 w-24 rounded bg-[#eef3fb]" />
...@@ -288,10 +289,10 @@ function News() { ...@@ -288,10 +289,10 @@ function News() {
<Link <Link
key={news.id} key={news.id}
href={news.externalLink} 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"> <div className="h-16 w-16 shrink-0 overflow-hidden rounded-[14px]">
<ImageNext <Image
src={news.thumbnail?.url ?? "/thumbnail.png"} src={news.thumbnail?.url ?? "/thumbnail.png"}
alt={news.thumbnail?.alt || news.title} alt={news.thumbnail?.alt || news.title}
width={160} width={160}
...@@ -346,9 +347,9 @@ function News() { ...@@ -346,9 +347,9 @@ function News() {
) : ( ) : (
<div <div
key={`news-placeholder-${index}`} 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="min-w-0 flex-1">
<div className="h-5 w-5/6 rounded bg-[#eef3fb]" /> <div className="h-5 w-5/6 rounded bg-[#eef3fb]" />
<div className="mt-2 h-3 w-24 rounded bg-[#f4f7fb]" /> <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,8 +36,7 @@ function PolicyAndLaws() { ...@@ -36,8 +36,7 @@ function PolicyAndLaws() {
<Link <Link
key={item.id} key={item.id}
href={item.externalLink} 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)] ${ 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" : ""
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" /> <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() { ...@@ -63,7 +62,7 @@ function PolicyAndLaws() {
) : ( ) : (
<div <div
key={`policy-placeholder-${index}`} 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" /> <span className="mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
......
...@@ -19,7 +19,7 @@ function AdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: strin ...@@ -19,7 +19,7 @@ function AdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: strin
href={item.link || FALLBACK_HREF} href={item.link || FALLBACK_HREF}
target="_blank" target="_blank"
rel="noopener noreferrer" 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} title={item.name}
> >
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]"> <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 }) { ...@@ -45,7 +45,7 @@ function FallbackAdItem({ src }: { src: string }) {
href={FALLBACK_HREF} href={FALLBACK_HREF}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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]"> <div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
......
"use client"; "use client";
import ImageNext from "@/components/shared/image-next"; import Image from "next/image";
import partnerImages from "@/constants/partnerImages"; import partnerImages from "@/constants/partnerImages";
import { ChevronRight, Play } from "lucide-react"; import { ChevronRight, Play } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
...@@ -8,7 +8,7 @@ import { useGetOrganizations } from "@/api/vcci-hcm/endpoints/organizations"; ...@@ -8,7 +8,7 @@ import { useGetOrganizations } from "@/api/vcci-hcm/endpoints/organizations";
import type { Organization } from "@/api/vcci-hcm/models"; import type { Organization } from "@/api/vcci-hcm/models";
import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video"; import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video";
import type { Video } from "@/api/vcci-news/models/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 { getVideoThumbnail, normalizeVideoUrl } from "@/lib/utils/video";
import { Autoplay } from "swiper/modules"; import { Autoplay } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
...@@ -67,10 +67,10 @@ const renderPartnerContent = (partners: Organization[]) => { ...@@ -67,10 +67,10 @@ const renderPartnerContent = (partners: Organization[]) => {
rel="noreferrer" rel="noreferrer"
className="block" 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]"> <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]">
<ImageNext <Image
src={resolvePartnerImage(partner.avatar, index)} src={resolvePartnerImage(partner.avatar, index) ?? "/img-error.png"}
alt={partner.name} alt={partner.name ?? ""}
width={140} width={140}
height={72} height={72}
className="max-h-full w-full object-contain" className="max-h-full w-full object-contain"
...@@ -78,10 +78,10 @@ const renderPartnerContent = (partners: Organization[]) => { ...@@ -78,10 +78,10 @@ const renderPartnerContent = (partners: Organization[]) => {
</div> </div>
</a> </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]"> <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]">
<ImageNext <Image
src={resolvePartnerImage(partner.avatar, index)} src={resolvePartnerImage(partner.avatar, index) ?? "/img-error.png"}
alt={partner.name} alt={partner.name ?? ""}
width={140} width={140}
height={72} height={72}
className="max-h-full w-full object-contain" className="max-h-full w-full object-contain"
...@@ -95,7 +95,7 @@ const renderPartnerContent = (partners: Organization[]) => { ...@@ -95,7 +95,7 @@ const renderPartnerContent = (partners: Organization[]) => {
} }
return ( 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. Chưa có thông tin.
</div> </div>
); );
...@@ -179,7 +179,7 @@ function VideoAndPartners() { ...@@ -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)]" 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"> <div className="group relative aspect-[1.95/1] overflow-hidden">
<ImageNext <Image
src={video.thumbnail} src={video.thumbnail}
alt={video.name} alt={video.name}
width={640} width={640}
......
...@@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query"; ...@@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getApiV10Post, useGetApiV10Post } from "@/api/vcci-news/endpoints/post"; import { getApiV10Post, useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import Links from "@/links"; import Links from "@/links";
import { MOCK_HOME_POSTS } from "@lib/mock-home-posts"; import { MOCK_HOME_POSTS } from "@/mockdata/home-posts";
type RawHomeCategory = { type RawHomeCategory = {
id?: string | null; id?: string | null;
......
...@@ -22,7 +22,7 @@ export default function HomePage() { ...@@ -22,7 +22,7 @@ export default function HomePage() {
<section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0"> <section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0">
<News /> <News />
<Advertisements count={2} startIndex={0} /> <Advertisements count={3} startIndex={0} />
</section > </section >
<HorizontalAdBanner /> <HorizontalAdBanner />
......
'use client'; 'use client';
import dayjs from "dayjs"; import dayjs from "dayjs";
import ImageNext from "@/components/shared/image-next";
import AppEditorContent from "@/components/shared/editor-content"; import AppEditorContent from "@/components/shared/editor-content";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar"; import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
......
...@@ -5,7 +5,7 @@ import Link from "next/link"; ...@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
...@@ -155,7 +155,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -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)]" 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]"> <div className="relative overflow-hidden rounded-md bg-[#edf1f5] aspect-[25/15] sm:aspect-[5/3]">
<ImageNext <Image
src={resolveDynamicPostImage(item.thumbnail)} src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title} alt={item.title}
width={520} width={520}
......
...@@ -5,7 +5,7 @@ import Link from "next/link"; ...@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
...@@ -108,7 +108,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp ...@@ -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="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]">
<div className="relative aspect-3/4 overflow-hidden bg-white"> <div className="relative aspect-3/4 overflow-hidden bg-white">
<ImageNext <Image
src={resolveDynamicPostImage(item.thumbnail)} src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title} alt={item.title}
width={520} width={520}
......
"use client"; "use client";
import parse from "html-react-parser"; import parse from "html-react-parser";
import ImageNext from "@/components/shared/image-next"; import Image from "next/image";
import { getDynamicPostBodyHtml } from "./data"; import { getDynamicPostBodyHtml } from "./data";
import type { DynamicPostContentSection, DynamicPostItem } from "./types"; import type { DynamicPostContentSection, DynamicPostItem } from "./types";
...@@ -32,7 +32,7 @@ function StructuredImageSection({ section }: { section: DynamicPostContentSectio ...@@ -32,7 +32,7 @@ function StructuredImageSection({ section }: { section: DynamicPostContentSectio
key={`${section.id}-${image.id || image.url}-${item.position}`} key={`${section.id}-${image.id || image.url}-${item.position}`}
className="overflow-hidden rounded-[18px] bg-white" className="overflow-hidden rounded-[18px] bg-white"
> >
<ImageNext <Image
src={image.url} src={image.url}
alt={image.alt || image.name || "Hình ảnh bài viết"} alt={image.alt || image.name || "Hình ảnh bài viết"}
width={1200} width={1200}
......
...@@ -499,15 +499,6 @@ export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) { ...@@ -499,15 +499,6 @@ export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) {
return Links.resolveImageUrl(value); 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 { export function getDynamicPostSeoImage(post: DynamicPostItem | null): string {
if (!post) return "/thumbnail.png"; if (!post) return "/thumbnail.png";
...@@ -524,14 +515,6 @@ export function getDynamicPostSeoImage(post: DynamicPostItem | null): string { ...@@ -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"; return "/thumbnail.png";
} }
......
...@@ -6,8 +6,9 @@ import { ShieldCheck, Target, Zap } from "lucide-react"; ...@@ -6,8 +6,9 @@ import { ShieldCheck, Target, Zap } from "lucide-react";
import parse from "html-react-parser"; import parse from "html-react-parser";
import Link from "next/link"; import Link from "next/link";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post"; 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 { buildDynamicPostHref, buildVisibleNewsFilters, stripHtml } from "../data";
import links from "@/links";
import StructuredPostContent from "../StructuredPostContent"; import StructuredPostContent from "../StructuredPostContent";
import type { DynamicPostItem } from "../types"; import type { DynamicPostItem } from "../types";
...@@ -121,10 +122,12 @@ export default function AboutVcciHcmPage({ ...@@ -121,10 +122,12 @@ export default function AboutVcciHcmPage({
externalLink: buildDynamicPostHref(item.external_link?.trim() || "#", item.id ? String(item.id) : ""), externalLink: buildDynamicPostHref(item.external_link?.trim() || "#", item.id ? String(item.id) : ""),
publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""), publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""),
thumbnailUrl: thumbnailUrl:
links.resolveImageUrl(
item.thumbnail?.url?.trim() || item.thumbnail?.url?.trim() ||
item.thumbnail?.path?.trim() || item.thumbnail?.path?.trim() ||
item.thumbnail?.original?.trim() || item.thumbnail?.original?.trim() ||
"/thumbnail.png", "",
) || "/thumbnail.png",
thumbnailAlt: String(item.title ?? "").trim() || "Tin VCCI", thumbnailAlt: String(item.title ?? "").trim() || "Tin VCCI",
})), })),
}, },
...@@ -322,7 +325,7 @@ export default function AboutVcciHcmPage({ ...@@ -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" 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"> <div className="relative aspect-[1.28] overflow-hidden">
<ImageNext <Image
src={item.thumbnailUrl} src={item.thumbnailUrl}
alt={item.thumbnailAlt} alt={item.thumbnailAlt}
width={720} width={720}
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FileText, Globe2, Newspaper, TrendingUp } from "lucide-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"; import type { DynamicPostItem } from "../types";
type MarketProfilePageProps = { type MarketProfilePageProps = {
...@@ -256,7 +256,7 @@ export default function MarketProfilePage({ post }: MarketProfilePageProps) { ...@@ -256,7 +256,7 @@ export default function MarketProfilePage({ post }: MarketProfilePageProps) {
})} })}
</div> </div>
<ImageNext <Image
src={activeRegion.image} src={activeRegion.image}
alt={activeRegion.imageAlt} alt={activeRegion.imageAlt}
width={1200} width={1200}
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; 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 SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Pagination } from "@components/base/pagination"; import { Pagination } from "@components/base/pagination";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -54,7 +54,7 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe ...@@ -54,7 +54,7 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]" className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
> >
<div className="overflow-hidden rounded-md bg-[#edf1f5]"> <div className="overflow-hidden rounded-md bg-[#edf1f5]">
<ImageNext <Image
src={resolveDynamicPostImage(item.thumbnail)} src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title} alt={item.title}
width={520} width={520}
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Play } from "lucide-react"; import { Play } from "lucide-react";
import ImageNext from "@/components/shared/image-next"; import Image from "next/image";
import { Pagination } from "@/components/base/pagination"; import { Pagination } from "@/components/base/pagination";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video"; import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video";
...@@ -106,7 +106,7 @@ function VideoPageContent() { ...@@ -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)]" 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]"> <div className="relative aspect-video overflow-hidden bg-[#edf1f5]">
<ImageNext <Image
src={video.thumbnail} src={video.thumbnail}
alt={video.name} alt={video.name}
width={900} 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"; "use client";
import React from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
...@@ -36,41 +35,12 @@ import { ...@@ -36,41 +35,12 @@ import {
} from "@/lib/api/cms-admin"; } from "@/lib/api/cms-admin";
import { ADMIN_NEWS_TYPE_LABELS } from "@/mockdata/admin-news"; import { ADMIN_NEWS_TYPE_LABELS } from "@/mockdata/admin-news";
import { buildHeaderCategoryTree } from "@/mockdata/header-config"; import { buildHeaderCategoryTree } from "@/mockdata/header-config";
import { HeaderCategoryPostsLoading } from "./_components/HeaderCategoryPostsLoading";
const PAGE_SIZE = 10; import {
PAGE_SIZE,
function formatDateTime(value: string) { flattenTree,
return value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "—"; formatDateTime,
} } from "./_components/utils";
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>
));
}
export default function HeaderCategoryPostsPage() { export default function HeaderCategoryPostsPage() {
const params = useParams(); const params = useParams();
...@@ -78,19 +48,19 @@ export default function HeaderCategoryPostsPage() { ...@@ -78,19 +48,19 @@ export default function HeaderCategoryPostsPage() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const categoryId = String(params.categoryId ?? ""); const categoryId = String(params.categoryId ?? "");
const [items, setItems] = React.useState<CmsNewsItem[]>([]); const [items, setItems] = useState<CmsNewsItem[]>([]);
const [headerItems, setHeaderItems] = React.useState<CmsHeaderCategoryItem[]>([]); const [headerItems, setHeaderItems] = useState<CmsHeaderCategoryItem[]>([]);
const [search, setSearch] = React.useState(() => searchParams.get("q") ?? ""); const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
const [ready, setReady] = React.useState(false); const [ready, setReady] = useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<CmsNewsItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<CmsNewsItem | null>(null);
const didMountRef = React.useRef(false); const didMountRef = useRef(false);
const [total, setTotal] = React.useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = React.useState(() => { const [page, setPage] = useState(() => {
const parsedPage = Number(searchParams.get("page") ?? 1); const parsedPage = Number(searchParams.get("page") ?? 1);
return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1; return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1;
}); });
const listQueryString = React.useMemo(() => { const listQueryString = useMemo(() => {
const nextParams = new URLSearchParams(); const nextParams = new URLSearchParams();
if (page > 1) { if (page > 1) {
...@@ -104,12 +74,12 @@ export default function HeaderCategoryPostsPage() { ...@@ -104,12 +74,12 @@ export default function HeaderCategoryPostsPage() {
return nextParams.toString(); return nextParams.toString();
}, [page, search]); }, [page, search]);
const listPath = React.useMemo( const listPath = useMemo(
() => (listQueryString ? `${pathname}?${listQueryString}` : pathname), () => (listQueryString ? `${pathname}?${listQueryString}` : pathname),
[listQueryString, pathname], [listQueryString, pathname],
); );
React.useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const load = async () => { const load = async () => {
...@@ -151,11 +121,11 @@ export default function HeaderCategoryPostsPage() { ...@@ -151,11 +121,11 @@ export default function HeaderCategoryPostsPage() {
}; };
}, [categoryId, page, search]); }, [categoryId, page, search]);
const flatCategories = React.useMemo(() => { const flatCategories = useMemo(() => {
return flattenTree(buildHeaderCategoryTree(headerItems)); return flattenTree(buildHeaderCategoryTree(headerItems));
}, [headerItems]); }, [headerItems]);
const category = React.useMemo( const category = useMemo(
() => flatCategories.find((item) => item.id === categoryId) ?? null, () => flatCategories.find((item) => item.id === categoryId) ?? null,
[categoryId, flatCategories], [categoryId, flatCategories],
); );
...@@ -166,16 +136,16 @@ export default function HeaderCategoryPostsPage() { ...@@ -166,16 +136,16 @@ export default function HeaderCategoryPostsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const isSinglePostCategory = category?.type === "page"; 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 (!ready) return;
if (!category || !canManagePosts) { if (!category || !canManagePosts) {
router.replace("/admin/header-config"); router.replace("/admin/header-config");
} }
}, [canManagePosts, category, ready, router]); }, [canManagePosts, category, ready, router]);
React.useEffect(() => { useEffect(() => {
const nextPath = listQueryString ? `${pathname}?${listQueryString}` : pathname; const nextPath = listQueryString ? `${pathname}?${listQueryString}` : pathname;
const currentPath = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`; const currentPath = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
...@@ -184,7 +154,7 @@ export default function HeaderCategoryPostsPage() { ...@@ -184,7 +154,7 @@ export default function HeaderCategoryPostsPage() {
} }
}, [listQueryString, pathname, router, searchParams]); }, [listQueryString, pathname, router, searchParams]);
React.useEffect(() => { useEffect(() => {
if (!didMountRef.current) { if (!didMountRef.current) {
didMountRef.current = true; didMountRef.current = true;
return; return;
...@@ -193,13 +163,13 @@ export default function HeaderCategoryPostsPage() { ...@@ -193,13 +163,13 @@ export default function HeaderCategoryPostsPage() {
setPage((currentPage) => (currentPage === 1 ? currentPage : 1)); setPage((currentPage) => (currentPage === 1 ? currentPage : 1));
}, [search]); }, [search]);
React.useEffect(() => { useEffect(() => {
if (totalPages > 0 && page > totalPages) { if (totalPages > 0 && page > totalPages) {
setPage(totalPages); setPage(totalPages);
} }
}, [page, totalPages]); }, [page, totalPages]);
const stats = React.useMemo(() => { const stats = useMemo(() => {
return [ return [
{ {
label: "Tổng bài viết", label: "Tổng bài viết",
...@@ -387,7 +357,7 @@ export default function HeaderCategoryPostsPage() { ...@@ -387,7 +357,7 @@ export default function HeaderCategoryPostsPage() {
label: "Chỉnh sửa bài viết", label: "Chỉnh sửa bài viết",
onClick: () => onClick: () =>
router.push( 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`}
/>
);
}
...@@ -232,7 +232,7 @@ export function HeaderCategoryTable({ ...@@ -232,7 +232,7 @@ export function HeaderCategoryTable({
{ {
kind: "manage" as const, kind: "manage" as const,
label: "Quản lý bài viết", label: "Quản lý bài viết",
href: `/admin/header-config/${item.id}/posts`, href: `/admin/header-config/${item.id}`,
}, },
] ]
: []), : []),
......
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"; "use client";
import * as React from "react"; import { useEffect, useState } from "react";
import { ArrowLeft, Save, Upload, X } from "lucide-react"; import { ArrowLeft, Save, Upload, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
...@@ -61,14 +61,14 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) { ...@@ -61,14 +61,14 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
const router = useRouter(); const router = useRouter();
const isNew = !memberId || memberId === "new"; const isNew = !memberId || memberId === "new";
const [form, setForm] = React.useState<MemberFormValues>(EMPTY_MEMBER_FORM); const [form, setForm] = useState<MemberFormValues>(EMPTY_MEMBER_FORM);
const [fields, setFields] = React.useState<MemberField[]>([]); const [fields, setFields] = useState<MemberField[]>([]);
const [regions, setRegions] = React.useState<MemberRegion[]>([]); const [regions, setRegions] = useState<MemberRegion[]>([]);
const [imagePickerOpen, setImagePickerOpen] = React.useState(false); const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = useState(false);
const [ready, setReady] = React.useState(false); const [ready, setReady] = useState(false);
React.useEffect(() => { useEffect(() => {
const allFields = readMemberFields(); const allFields = readMemberFields();
const allRegions = readMemberRegions(); const allRegions = readMemberRegions();
setFields(allFields); setFields(allFields);
......
import { AdminMemberForm } from "@/components/admin/member-form"; import { AdminMemberForm } from "./_components/member-form";
interface AdminMemberDetailPageProps { interface AdminMemberDetailPageProps {
params: Promise<{ id: string }>; 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"; "use client";
import * as React from "react"; import { useEffect, useMemo, useState } from "react";
import { Plus, Save, X } from "lucide-react"; import { Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
Table, Table,
TableBody, TableBody,
...@@ -29,93 +20,22 @@ import { ...@@ -29,93 +20,22 @@ import {
persistMemberFields, persistMemberFields,
readMemberFields, readMemberFields,
} from "@/mockdata/members"; } from "@/mockdata/members";
import { FieldFormDialog } from "./_components/field-form-dialog";
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>
);
}
export default function AdminMemberFieldsPage() { export default function AdminMemberFieldsPage() {
const [items, setItems] = React.useState<MemberField[]>([]); const [items, setItems] = useState<MemberField[]>([]);
const [search, setSearch] = React.useState(""); const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editTarget, setEditTarget] = React.useState<MemberField | null>(null); const [editTarget, setEditTarget] = useState<MemberField | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MemberField | null>(null); const [deleteTarget, setDeleteTarget] = useState<MemberField | null>(null);
const [ready, setReady] = React.useState(false); const [ready, setReady] = useState(false);
React.useEffect(() => { useEffect(() => {
setItems(readMemberFields()); setItems(readMemberFields());
setReady(true); setReady(true);
}, []); }, []);
const filtered = React.useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
if (!keyword) return items; if (!keyword) return items;
return items.filter((item) => item.name.toLowerCase().includes(keyword)); return items.filter((item) => item.name.toLowerCase().includes(keyword));
......
"use client"; "use client";
import * as React from "react"; import { useEffect, useMemo, useState } from "react";
import { Plus, Users } from "lucide-react"; import { Plus, Users } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid"; import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
...@@ -35,46 +24,29 @@ import { ...@@ -35,46 +24,29 @@ import {
readMemberRegions, readMemberRegions,
readMembers, readMembers,
} from "@/mockdata/members"; } from "@/mockdata/members";
import { MemberFilters } from "./_components/member-filters";
const selectTriggerClassName = import { MemberRow } from "./_components/member-row";
"w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[200px]"; import { MemberTableLoading } from "./_components/member-table-loading";
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>
));
}
export default function AdminMembersPage() { export default function AdminMembersPage() {
const router = useRouter(); const router = useRouter();
const [items, setItems] = React.useState<MemberItem[]>([]); const [items, setItems] = useState<MemberItem[]>([]);
const [fields, setFields] = React.useState<MemberField[]>([]); const [fields, setFields] = useState<MemberField[]>([]);
const [regions, setRegions] = React.useState<MemberRegion[]>([]); const [regions, setRegions] = useState<MemberRegion[]>([]);
const [search, setSearch] = React.useState(""); const [search, setSearch] = useState("");
const [fieldFilter, setFieldFilter] = React.useState("all"); const [fieldFilter, setFieldFilter] = useState("all");
const [regionFilter, setRegionFilter] = React.useState("all"); const [regionFilter, setRegionFilter] = useState("all");
const [deleteTarget, setDeleteTarget] = React.useState<MemberItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<MemberItem | null>(null);
const [ready, setReady] = React.useState(false); const [ready, setReady] = useState(false);
React.useEffect(() => { useEffect(() => {
setItems(readMembers()); setItems(readMembers());
setFields(readMemberFields()); setFields(readMemberFields());
setRegions(readMemberRegions()); setRegions(readMemberRegions());
setReady(true); setReady(true);
}, []); }, []);
const filtered = React.useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return items.filter((item) => { return items.filter((item) => {
...@@ -91,7 +63,7 @@ export default function AdminMembersPage() { ...@@ -91,7 +63,7 @@ export default function AdminMembersPage() {
}); });
}, [items, search, fieldFilter, regionFilter]); }, [items, search, fieldFilter, regionFilter]);
const stats = React.useMemo( const stats = useMemo(
() => [ () => [
{ {
label: "Tổng hội viên", label: "Tổng hội viên",
...@@ -112,12 +84,12 @@ export default function AdminMembersPage() { ...@@ -112,12 +84,12 @@ export default function AdminMembersPage() {
[items, fields, regions], [items, fields, regions],
); );
const fieldMap = React.useMemo( const fieldMap = useMemo(
() => Object.fromEntries(fields.map((f) => [f.id, f.name])), () => Object.fromEntries(fields.map((f) => [f.id, f.name])),
[fields], [fields],
); );
const regionMap = React.useMemo( const regionMap = useMemo(
() => Object.fromEntries(regions.map((r) => [r.id, r.name])), () => Object.fromEntries(regions.map((r) => [r.id, r.name])),
[regions], [regions],
); );
...@@ -143,39 +115,14 @@ export default function AdminMembersPage() { ...@@ -143,39 +115,14 @@ export default function AdminMembersPage() {
onSearchChange={setSearch} onSearchChange={setSearch}
onActionClick={() => router.push("/admin/members/new")} onActionClick={() => router.push("/admin/members/new")}
filters={ filters={
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> <MemberFilters
<Select value={fieldFilter} onValueChange={setFieldFilter}> fields={fields}
<SelectTrigger className={selectTriggerClassName}> regions={regions}
<SelectValue placeholder="Lĩnh vực" /> fieldFilter={fieldFilter}
</SelectTrigger> regionFilter={regionFilter}
<SelectContent className={selectContentClassName}> onFieldFilterChange={setFieldFilter}
<SelectItem value="all" className={selectItemClassName}> onRegionFilterChange={setRegionFilter}
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>
} }
> >
<div className="overflow-x-auto"> <div className="overflow-x-auto">
...@@ -202,72 +149,15 @@ export default function AdminMembersPage() { ...@@ -202,72 +149,15 @@ export default function AdminMembersPage() {
</TableRow> </TableRow>
) : ( ) : (
filtered.map((item, index) => ( filtered.map((item, index) => (
<TableRow <MemberRow
key={item.id} key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"} item={item}
> index={index}
<TableCell className="px-4 py-3 text-sm font-medium text-gray-800"> fieldName={fieldMap[item.field_id]}
<div className="space-y-1"> regionName={regionMap[item.region_id]}
<div>{item.name}</div> onEdit={() => router.push(`/admin/members/${item.id}`)}
{item.is_featured ? ( onDelete={() => setDeleteTarget(item)}
<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>
)) ))
)} )}
</TableBody> </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"; "use client";
import * as React from "react"; import { useEffect, useMemo, useState } from "react";
import { Plus, Save, X } from "lucide-react"; import { Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
Table, Table,
TableBody, TableBody,
...@@ -29,93 +20,22 @@ import { ...@@ -29,93 +20,22 @@ import {
persistMemberRegions, persistMemberRegions,
readMemberRegions, readMemberRegions,
} from "@/mockdata/members"; } from "@/mockdata/members";
import { RegionFormDialog } from "./_components/region-form-dialog";
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>
);
}
export default function AdminMemberRegionsPage() { export default function AdminMemberRegionsPage() {
const [items, setItems] = React.useState<MemberRegion[]>([]); const [items, setItems] = useState<MemberRegion[]>([]);
const [search, setSearch] = React.useState(""); const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editTarget, setEditTarget] = React.useState<MemberRegion | null>(null); const [editTarget, setEditTarget] = useState<MemberRegion | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MemberRegion | null>(null); const [deleteTarget, setDeleteTarget] = useState<MemberRegion | null>(null);
const [ready, setReady] = React.useState(false); const [ready, setReady] = useState(false);
React.useEffect(() => { useEffect(() => {
setItems(readMemberRegions()); setItems(readMemberRegions());
setReady(true); setReady(true);
}, []); }, []);
const filtered = React.useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
if (!keyword) return items; if (!keyword) return items;
return items.filter((item) => item.name.toLowerCase().includes(keyword)); 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"; "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 { Clock, ChevronDown, ChevronUp, User, Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { useGetApiV10PostIdHistory } from "@/api/vcci-news/endpoints/post"; import { useGetApiV10PostIdHistory } from "@/api/vcci-news/endpoints/post";
import type { PostHistoryItem, PostHistoryAction } from "@/api/vcci-news/types/post-history"; import type { PostHistoryItem, PostHistoryAction } from "@/api/vcci-news/types/post-history";
...@@ -10,7 +10,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component ...@@ -10,7 +10,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
const ACTION_CONFIG: Record< const ACTION_CONFIG: Record<
PostHistoryAction, PostHistoryAction,
{ label: string; icon: React.ComponentType<{ className?: string }>; className: string } { label: string; icon: ComponentType<{ className?: string }>; className: string }
> = { > = {
CREATE: { CREATE: {
label: "Tạo bài viết", label: "Tạo bài viết",
...@@ -73,7 +73,7 @@ function formatValue(value: unknown): string { ...@@ -73,7 +73,7 @@ function formatValue(value: unknown): string {
function HistoryItem({ item }: { item: PostHistoryItem }) { function HistoryItem({ item }: { item: PostHistoryItem }) {
const config = ACTION_CONFIG[item.action]; const config = ACTION_CONFIG[item.action];
const Icon = config.icon; const Icon = config.icon;
const [isOpen, setIsOpen] = React.useState(item.action === "CREATE"); const [isOpen, setIsOpen] = useState(item.action === "CREATE");
return ( return (
<div className="rounded-lg border border-gray-200 bg-white"> <div className="rounded-lg border border-gray-200 bg-white">
...@@ -181,13 +181,13 @@ interface PostHistoryViewerProps { ...@@ -181,13 +181,13 @@ interface PostHistoryViewerProps {
} }
export function PostHistoryViewer({ postId }: 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 // 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 // the panel. Previously `enabled: isOpen` caused the count to show "0 thay
// đổi" until the panel was opened. // đổi" until the panel was opened.
const { data, isLoading, isError, error } = useGetApiV10PostIdHistory(postId); const { data, isLoading, isError, error } = useGetApiV10PostIdHistory(postId);
const historyItems = React.useMemo(() => { const historyItems = useMemo(() => {
// useQuery's `data` IS the API response body: { responseData: [...], ... } // useQuery's `data` IS the API response body: { responseData: [...], ... }
const raw = data as unknown as { responseData?: PostHistoryItem[]; data?: PostHistoryItem[] } | undefined; const raw = data as unknown as { responseData?: PostHistoryItem[]; data?: PostHistoryItem[] } | undefined;
if (!raw) return []; 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 { export default function AdminNewsDetailPage() {
params: Promise<{ return (
id: string; <Suspense
}>; fallback={
searchParams: Promise<{ <div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700">
returnTo?: string; Đang tải...
}>; </div>
} }
>
export default async function AdminNewsDetailPage({ <AdminNewsFormContent />
params, </Suspense>
searchParams, );
}: AdminNewsDetailPageProps) {
const { id } = await params;
const { returnTo } = await searchParams;
return <AdminNewsForm newsId={id} returnPath={returnTo || "/admin/news"} />;
} }
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>
));
}
"use client";
import { useState } 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 { type HeaderCategoryItem } from "@/mockdata/header-config";
import { formatHeaderCategoryOptionLabel } from "./utils";
export function CategoryFilterCombobox({
value,
options,
onChange,
}: {
value: string;
options: Array<HeaderCategoryItem & { depth: number }>;
onChange: (value: string) => void;
}) {
const [open, setOpen] = 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}
className={cn(
"h-10 w-full justify-between rounded-xl border-[#063e8e]/15 bg-white px-3 font-normal text-gray-700 hover:bg-white hover:text-gray-700 focus-visible:ring-[#063e8e]/30 lg:w-[220px]",
!selectedOption && "text-gray-700",
)}
>
<span className="truncate text-left">
{selectedOption
? formatHeaderCategoryOptionLabel(selectedOption)
: "T\u1ea5t c\u1ea3 danh m\u1ee5c"}
</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\u00ecm danh m\u1ee5c hi\u1ec3n th\u1ecb"}
className="text-gray-700 placeholder:text-gray-500"
/>
<CommandList className="max-h-72">
<CommandEmpty className="text-gray-700">
{"Kh\u00f4ng t\u00ecm th\u1ea5y danh m\u1ee5c ph\u00f9 h\u1ee3p"}
</CommandEmpty>
<CommandItem
value="all Tat ca danh muc"
onSelect={() => {
onChange("all");
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 === "all" ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{"T\u1ea5t c\u1ea3 danh m\u1ee5c"}</span>
</CommandItem>
{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>
);
}
This diff is collapsed.
This diff is collapsed.
"use client"; "use client";
import Link from "next/link";
import { usePermission } from "@/hooks/usePermission";
import { NoPermissionMessage } from "@/components/shared/permission-gate"; import { NoPermissionMessage } from "@/components/shared/permission-gate";
export default function NoPermissionPage() { 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 };
import { toCmsSlug } from "@/lib/utils/cms-slug";
export const fieldClassName =
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
export const slugifyTag = (value: string) => toCmsSlug(value);
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