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

optimize image

parent cd1a8b50
...@@ -10,6 +10,7 @@ const orvalConfig = defineConfig({ ...@@ -10,6 +10,7 @@ const orvalConfig = defineConfig({
target: "src/api/vcci-news/endpoints/index.ts", target: "src/api/vcci-news/endpoints/index.ts",
schemas: "src/api/vcci-news/models", schemas: "src/api/vcci-news/models",
client: "react-query", client: "react-query",
clean: true,
override: { override: {
query: { query: {
useInfinite: true, useInfinite: true,
......
...@@ -152,6 +152,9 @@ importers: ...@@ -152,6 +152,9 @@ importers:
react-resizable-panels: react-resizable-panels:
specifier: ^3.0.6 specifier: ^3.0.6
version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
sharp:
specifier: 0.34.5
version: 0.34.5
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
...@@ -4251,8 +4254,7 @@ snapshots: ...@@ -4251,8 +4254,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
'@img/colour@1.0.0': '@img/colour@1.0.0': {}
optional: true
'@img/sharp-darwin-arm64@0.34.5': '@img/sharp-darwin-arm64@0.34.5':
optionalDependencies: optionalDependencies:
...@@ -7466,7 +7468,6 @@ snapshots: ...@@ -7466,7 +7468,6 @@ snapshots:
'@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5 '@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
......
"use client"; "use client";
import { SafeImage } from "@/components/shared/safe-image"; 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,9 +84,8 @@ function BannerSlideItem({ ...@@ -84,9 +84,8 @@ function BannerSlideItem({
: "/thumbnail.png"; : "/thumbnail.png";
return ( return (
<SafeImage <Image
src={url} src={url}
fallbackSrc="/thumbnail.png"
alt={alt} alt={alt}
width={2560} width={2560}
height={720} height={720}
......
'use client'; 'use client';
import { SafeImage } from "@/components/shared/safe-image"; 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";
...@@ -48,9 +48,8 @@ function Events() { ...@@ -48,9 +48,8 @@ function Events() {
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)]" 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]">
<SafeImage <Image
src={featuredEvent.thumbnail?.url} src={featuredEvent.thumbnail?.url ?? featuredFallback}
fallbackSrc={featuredFallback}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title} alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720} width={720}
height={520} height={520}
...@@ -115,9 +114,8 @@ function Events() { ...@@ -115,9 +114,8 @@ function Events() {
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" 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-[14px]"> <div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[14px]">
<SafeImage <Image
src={item.thumbnail?.url} src={item.thumbnail?.url ?? sideFallbacks[index] ?? featuredFallback}
fallbackSrc={sideFallbacks[index]}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
width={160} width={160}
height={160} height={160}
......
'use client'; 'use client';
import { SafeImage } from "@/components/shared/safe-image"; 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";
...@@ -51,9 +51,8 @@ function FeaturedNews() { ...@@ -51,9 +51,8 @@ function FeaturedNews() {
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]" 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]">
<SafeImage <Image
src={primaryItem.thumbnail?.url} src={primaryItem.thumbnail?.url ?? primaryFallback}
fallbackSrc={primaryFallback}
alt={primaryItem.thumbnail?.alt || primaryItem.title} alt={primaryItem.thumbnail?.alt || primaryItem.title}
width={1200} width={1200}
height={800} height={800}
...@@ -98,9 +97,8 @@ function FeaturedNews() { ...@@ -98,9 +97,8 @@ function FeaturedNews() {
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]" 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]">
<SafeImage <Image
src={item.thumbnail?.url} src={item.thumbnail?.url ?? secondaryFallbacks[index] ?? primaryFallback}
fallbackSrc={secondaryFallbacks[index]}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
width={600} width={600}
height={420} height={420}
......
'use client'; 'use client';
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements"; import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import links from "@/links"; import links from "@/links";
...@@ -27,9 +27,8 @@ function HorizontalAdBanner() { ...@@ -27,9 +27,8 @@ function HorizontalAdBanner() {
style={{ aspectRatio: "1600 / 200" }} style={{ aspectRatio: "1600 / 200" }}
title={title} title={title}
> >
<SafeImage <Image
src={src} src={src}
fallbackSrc={FALLBACK_SRC}
alt={alt} alt={alt}
fill fill
sizes="100vw" sizes="100vw"
......
'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 { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import memberImages from "@/constants/memberImages"; import memberImages from "@/constants/memberImages";
import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/mockdata/bff-fallback"; import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/mockdata/bff-fallback";
...@@ -113,9 +113,8 @@ function Members() { ...@@ -113,9 +113,8 @@ 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">
<SafeImage <Image
src={resolveMemberImage(member.avatar, index)} src={resolveMemberImage(member.avatar, index)}
fallbackSrc="/img-error.png"
alt={member.name ?? ""} alt={member.name ?? ""}
width={260} width={260}
height={180} height={180}
...@@ -131,9 +130,8 @@ function Members() { ...@@ -131,9 +130,8 @@ 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">
<SafeImage <Image
src={resolveMemberImage(member.avatar, index)} src={resolveMemberImage(member.avatar, index)}
fallbackSrc="/img-error.png"
alt={member.name ?? ""} alt={member.name ?? ""}
width={260} width={260}
height={180} height={180}
...@@ -214,9 +212,8 @@ function Members() { ...@@ -214,9 +212,8 @@ function Members() {
className="group relative block cursor-pointer overflow-hidden rounded-[14px] 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]">
<SafeImage <Image
src={item.thumbnail?.url} src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
fallbackSrc={MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
width={520} width={520}
height={420} height={420}
...@@ -257,9 +254,8 @@ function Members() { ...@@ -257,9 +254,8 @@ function Members() {
className="group relative block cursor-pointer overflow-hidden rounded-[14px] 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">
<SafeImage <Image
src={item.thumbnail?.url} src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
fallbackSrc={MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
width={520} width={520}
height={420} height={420}
......
'use client'; 'use client';
import { SafeImage } from "@/components/shared/safe-image"; 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";
...@@ -83,9 +83,8 @@ function News() { ...@@ -83,9 +83,8 @@ function News() {
className="group block cursor-pointer overflow-hidden rounded-[16px] 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">
<SafeImage <Image
src={featuredArticle.thumbnail?.url} src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
fallbackSrc="/thumbnail.png"
alt={featuredArticle.thumbnail?.alt || featuredArticle.title} alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720} width={720}
height={580} height={580}
...@@ -152,9 +151,8 @@ function News() { ...@@ -152,9 +151,8 @@ function News() {
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)]" 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-20 w-20 shrink-0 overflow-hidden rounded-[14px]"> <div className="h-20 w-20 shrink-0 overflow-hidden rounded-[14px]">
<SafeImage <Image
src={news.thumbnail?.url} src={news.thumbnail?.url ?? "/thumbnail.png"}
fallbackSrc="/thumbnail.png"
alt={news.thumbnail?.alt || news.title} alt={news.thumbnail?.alt || news.title}
width={160} width={160}
height={160} height={160}
...@@ -229,9 +227,8 @@ function News() { ...@@ -229,9 +227,8 @@ function News() {
className="group block cursor-pointer overflow-hidden rounded-[16px] 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">
<SafeImage <Image
src={featuredArticle.thumbnail?.url} src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
fallbackSrc="/thumbnail.png"
alt={featuredArticle.thumbnail?.alt || featuredArticle.title} alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720} width={720}
height={580} height={580}
...@@ -295,9 +292,8 @@ function News() { ...@@ -295,9 +292,8 @@ function News() {
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)]" 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-[14px]"> <div className="h-16 w-16 shrink-0 overflow-hidden rounded-[14px]">
<SafeImage <Image
src={news.thumbnail?.url} src={news.thumbnail?.url ?? "/thumbnail.png"}
fallbackSrc="/thumbnail.png"
alt={news.thumbnail?.alt || news.title} alt={news.thumbnail?.alt || news.title}
width={160} width={160}
height={160} height={160}
......
'use client'; 'use client';
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements"; import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
...@@ -22,9 +22,8 @@ function AdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: strin ...@@ -22,9 +22,8 @@ function AdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: strin
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]">
<SafeImage <Image
src={src} src={src}
fallbackSrc={fallbackSrc}
alt={item.alt || item.name} alt={item.alt || item.name}
width={2048} width={2048}
height={1365} height={1365}
...@@ -45,7 +44,7 @@ function FallbackAdItem({ src }: { src: string }) { ...@@ -45,7 +44,7 @@ function FallbackAdItem({ src }: { src: string }) {
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]">
<SafeImage <Image
src={src} src={src}
alt="Quảng cáo VCCI HCM" alt="Quảng cáo VCCI HCM"
width={2048} width={2048}
......
"use client"; "use client";
import { SafeImage } from "@/components/shared/safe-image"; 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";
...@@ -68,9 +68,8 @@ const renderPartnerContent = (partners: Organization[]) => { ...@@ -68,9 +68,8 @@ const renderPartnerContent = (partners: Organization[]) => {
className="block" className="block"
> >
<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]"> <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]">
<SafeImage <Image
src={resolvePartnerImage(partner.avatar, index)} src={resolvePartnerImage(partner.avatar, index)}
fallbackSrc="/img-error.png"
alt={partner.name ?? ""} alt={partner.name ?? ""}
width={140} width={140}
height={72} height={72}
...@@ -80,9 +79,8 @@ const renderPartnerContent = (partners: Organization[]) => { ...@@ -80,9 +79,8 @@ const renderPartnerContent = (partners: Organization[]) => {
</a> </a>
) : ( ) : (
<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]"> <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]">
<SafeImage <Image
src={resolvePartnerImage(partner.avatar, index)} src={resolvePartnerImage(partner.avatar, index)}
fallbackSrc="/img-error.png"
alt={partner.name ?? ""} alt={partner.name ?? ""}
width={140} width={140}
height={72} height={72}
...@@ -181,7 +179,7 @@ function VideoAndPartners() { ...@@ -181,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">
<SafeImage <Image
src={video.thumbnail} src={video.thumbnail}
alt={video.name} alt={video.name}
width={640} width={640}
......
...@@ -6,7 +6,7 @@ import parse from "html-react-parser"; ...@@ -6,7 +6,7 @@ import parse from "html-react-parser";
import Link from "next/link"; import Link from "next/link";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post"; import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import links from "@/links"; import links from "@/links";
import { import {
buildDynamicPostHref, buildDynamicPostHref,
...@@ -338,7 +338,7 @@ export default function AboutVcciHcm({ post, category, allCategories }: AboutVcc ...@@ -338,7 +338,7 @@ export default function AboutVcciHcm({ post, category, allCategories }: AboutVcc
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">
<SafeImage <Image
src={item.thumbnailUrl} src={item.thumbnailUrl}
alt={item.thumbnailAlt} alt={item.thumbnailAlt}
width={720} width={720}
......
...@@ -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 { SafeImage } from "@/components/shared/safe-image"; 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 EventsCalendar from "@/app/(main)/(home)/components/events-calendar"; import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
...@@ -123,7 +123,7 @@ export default function AnPham({ category, allCategories }: AnPhamProps) { ...@@ -123,7 +123,7 @@ export default function AnPham({ category, allCategories }: AnPhamProps) {
> >
<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">
<SafeImage <Image
src={resolveDynamicPostImage(item.thumbnail)} src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title} alt={item.title}
width={520} width={520}
......
...@@ -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 { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import { buildDynamicCategoryMenu } from "../templates/data"; import { buildDynamicCategoryMenu } from "../templates/data";
import type { DynamicCategoryRouteItem } from "../templates/types"; import type { DynamicCategoryRouteItem } from "../templates/types";
...@@ -266,7 +266,7 @@ export default function MarketProfile({ post, category, allCategories }: MarketP ...@@ -266,7 +266,7 @@ export default function MarketProfile({ post, category, allCategories }: MarketP
})} })}
</div> </div>
<SafeImage <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 { SafeImage } from "@/components/shared/safe-image"; 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";
...@@ -57,7 +57,7 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe ...@@ -57,7 +57,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]">
<SafeImage <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 { SafeImage } from "@/components/shared/safe-image"; 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 EventsCalendar from "@/app/(main)/(home)/components/events-calendar"; import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
...@@ -123,7 +123,7 @@ export default function ThuVienTaiLieu({ category, allCategories }: ThuVienTaiLi ...@@ -123,7 +123,7 @@ export default function ThuVienTaiLieu({ category, allCategories }: ThuVienTaiLi
> >
<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">
<SafeImage <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 { SafeImage } from "@/components/shared/safe-image"; 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 ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
...@@ -87,7 +87,7 @@ function VideoPageContent() { ...@@ -87,7 +87,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]">
<SafeImage <Image
src={video.thumbnail} src={video.thumbnail}
alt={video.name} alt={video.name}
width={900} width={900}
......
...@@ -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 { SafeImage } from "@/components/shared/safe-image"; 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";
...@@ -158,7 +158,7 @@ export default function NewsPage({ category, allCategories }: NewsPageProps) { ...@@ -158,7 +158,7 @@ export default function NewsPage({ category, allCategories }: NewsPageProps) {
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]">
<SafeImage <Image
src={resolveDynamicPostImage(item.thumbnail)} src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title} alt={item.title}
width={520} width={520}
......
...@@ -45,7 +45,7 @@ import { ...@@ -45,7 +45,7 @@ import {
import { PermissionGate } from "@/components/shared/permission-gate"; import { PermissionGate } from "@/components/shared/permission-gate";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import { AdminImagePicker } from "@/components/admin/image-picker"; import { AdminImagePicker } from "@/components/admin/image-picker";
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import type { AdminMediaItem } from "@/mockdata/admin-news"; import type { AdminMediaItem } from "@/mockdata/admin-news";
import links from "@/links"; import links from "@/links";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
...@@ -369,7 +369,7 @@ export function AdvertisementList({ ...@@ -369,7 +369,7 @@ export function AdvertisementList({
style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 112 : 72 }} style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 112 : 72 }}
> >
{imageUrl ? ( {imageUrl ? (
<SafeImage <Image
src={imageUrl} src={imageUrl}
alt={ad.alt || ad.name} alt={ad.alt || ad.name}
fill fill
...@@ -514,7 +514,7 @@ export function AdvertisementList({ ...@@ -514,7 +514,7 @@ export function AdvertisementList({
style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 200 : 120 }} style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 200 : 120 }}
> >
{form.filePreviewUrl ? ( {form.filePreviewUrl ? (
<SafeImage <Image
src={form.filePreviewUrl} src={form.filePreviewUrl}
alt="Preview" alt="Preview"
fill fill
......
"use client"; "use client";
import Image from "next/image";
import type { BaseConfigBannerItem } from "@/api/vcci-news/types/base-config"; import type { BaseConfigBannerItem } from "@/api/vcci-news/types/base-config";
import type { AdminMediaItem } from "@/mockdata/admin-news"; import { useGetApiV10FileId } from "@/api/vcci-news/endpoints/file";
import { SafeImage } from "@/components/shared/safe-image"; import { toAdminMediaItem } from "@/lib/utils/file";
function BannerMedia({ fileId }: { fileId: string }) {
const { data } = useGetApiV10FileId(fileId, {
query: {
enabled: Boolean(fileId),
select: (response) =>
response.responseData ? toAdminMediaItem(response.responseData) : null,
},
});
return data ? (
<Image
src={data.url}
alt={data.alt || data.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>
);
}
export function ConfigItemPreview({ export function ConfigItemPreview({
title, title,
item, item,
media,
current, current,
onSelect, onSelect,
}: { }: {
title: string; title: string;
item: BaseConfigBannerItem; item: BaseConfigBannerItem;
media: AdminMediaItem | null;
current: boolean; current: boolean;
onSelect: () => void; onSelect: () => void;
}) { }) {
...@@ -21,24 +42,10 @@ export function ConfigItemPreview({ ...@@ -21,24 +42,10 @@ export function ConfigItemPreview({
<button <button
type="button" type="button"
onClick={onSelect} onClick={onSelect}
className={`overflow-hidden rounded-3xl border text-left transition-all ${current 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"}`}
? "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]"> <div className="relative aspect-[16/10] overflow-hidden bg-[#eef4ff]">
{media ? ( <BannerMedia fileId={item.imageId} />
<SafeImage
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>
<div className="space-y-2 px-4 py-3"> <div className="space-y-2 px-4 py-3">
<div className="line-clamp-1 text-sm font-semibold text-[#163b73]"> <div className="line-clamp-1 text-sm font-semibold text-[#163b73]">
......
import type { Banner, BannerMutate } from "@/api/vcci-news/models";
import type { BaseConfigBannerItem } from "@/mockdata/base-config";
import { createBaseConfigItemId } from "@/mockdata/base-config";
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",
};
}
"use client";
import { useMemo, useState } from "react";
import { ChevronLeft, ChevronRight, Edit, Plus, Trash2 } from "lucide-react";
import Image from "next/image";
import { toast } from "sonner";
import { TabsContent } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { ConfigItemDialog } from "../config-item-dialog";
import { ConfigItemPreview } from "./_components/ConfigItemPreview";
import {
useDeleteApiV10BannerId,
useGetApiV10Banner,
usePostApiV10Banner,
usePutApiV10BannerId,
} from "@/api/vcci-news/endpoints/banner";
import { useGetApiV10FileId } from "@/api/vcci-news/endpoints/file";
import { toAdminMediaItem } from "@/lib/utils/file";
import type { Banner } from "@/api/vcci-news/models";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import {
mapApiBannerToConfig,
mapConfigBannerToApi,
} from "./_components/utils";
import { type ConfigItemForm, type PageEnvelope } from "../types";
function CurrentBannerImage({
item,
}: {
item: ReturnType<typeof mapApiBannerToConfig>;
}) {
const { data } = useGetApiV10FileId(item.imageId, {
query: {
enabled: Boolean(item.imageId),
select: (response) =>
response.responseData ? toAdminMediaItem(response.responseData) : null,
},
});
return data ? (
<Image
src={data.url}
alt={data.alt || data.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
Chưa có banner được chọn
</div>
);
}
export function BannerTab() {
const emptyForm = (): ConfigItemForm => ({
name: "",
imageId: "",
isActive: true,
displayTimeSeconds: 5,
sortOrder: 1,
});
const {
data: bannerData,
isLoading: isBannerLoading,
refetch: refetchBanners,
} = useGetApiV10Banner({
page: 1,
pageSize: 100,
sortField: "display_order",
sortOrder: "asc",
});
const { mutateAsync: createBanner, isPending: isCreatingBanner } =
usePostApiV10Banner();
const { mutateAsync: updateBanner, isPending: isUpdatingBanner } =
usePutApiV10BannerId();
const { mutateAsync: deleteBanner } = useDeleteApiV10BannerId();
const bannerResponse = bannerData as
| {
responseData?: PageEnvelope<Banner>;
data?: { responseData?: PageEnvelope<Banner> };
}
| undefined;
const rows =
(bannerResponse?.responseData ?? bannerResponse?.data?.responseData)
?.rows ?? [];
const banners = useMemo(() => rows.map(mapApiBannerToConfig), [rows]);
const [currentIndex, setCurrentIndex] = useState(0);
const [form, setForm] = useState<ConfigItemForm>(emptyForm());
const [dialogOpen, setDialogOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ReturnType<
typeof mapApiBannerToConfig
> | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [selectedMedia, setSelectedMedia] = useState<AdminMediaItem | null>(
null,
);
const current = banners[currentIndex] ?? null;
const openCreate = () => {
setEditingId(null);
setSelectedMedia(null);
setForm({ ...emptyForm(), sortOrder: banners.length + 1 });
setDialogOpen(true);
};
const openEdit = () => {
if (!current) return;
setEditingId(current.id);
setForm({
name: current.name,
imageId: current.imageId,
isActive: current.isActive,
displayTimeSeconds: current.displayTimeSeconds,
sortOrder: current.sortOrder,
});
setDialogOpen(true);
};
const save = async () => {
if (!form.name.trim() || !form.imageId) {
toast.error("Vui lòng nhập tên và chọn hình ảnh");
return;
}
try {
const data = mapConfigBannerToApi({
...form,
id: editingId ?? "",
name: form.name.trim(),
});
if (editingId) await updateBanner({ id: editingId, data });
else await createBanner({ data });
setDialogOpen(false);
await refetchBanners();
toast.success("Đã lưu cấu hình banner");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Không thể lưu cấu hình banner",
);
}
};
const remove = async () => {
if (!deleteTarget) return;
try {
await deleteBanner({ id: deleteTarget.id });
setDeleteTarget(null);
await refetchBanners();
toast.success("Đã xóa cấu hình banner");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Không thể xóa cấu hình banner",
);
}
};
return (
<TabsContent value="banner" className="mt-0" aria-busy={isBannerLoading}>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">
Banner trang chủ
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý hình ảnh slider trang chủ.
</CardDescription>
</div>
<div className="flex flex-wrap gap-3">
<Button type="button" onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Thêm banner
</Button>
{current ? (
<>
<Button type="button" variant="outline" onClick={openEdit}>
<Edit className="mr-2 h-4 w-4" />
Sửa
</Button>
<Button
type="button"
variant="outline"
onClick={() => setDeleteTarget(current)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</Button>
</>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 px-4 sm:px-6">
<div className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
<div className="relative aspect-[16/6] overflow-hidden rounded-[24px] bg-[#eef4ff]">
{current ? (
<CurrentBannerImage item={current} />
) : (
<div className="flex h-full items-center justify-center text-gray-500">
Chưa có banner được chọn
</div>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm font-semibold uppercase text-[#4b74b8]">
Danh sách banner trang chủ
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
setCurrentIndex((value) =>
value <= 0 ? Math.max(banners.length - 1, 0) : value - 1,
)
}
disabled={banners.length <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
setCurrentIndex((value) =>
banners.length ? (value + 1) % banners.length : 0,
)
}
disabled={banners.length <= 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{banners.map((item, index) => (
<ConfigItemPreview
key={item.id}
title={`Banner ${index + 1}`}
item={item}
current={index === currentIndex}
onSelect={() => setCurrentIndex(index)}
/>
))}
</div>
{current ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div>
Tên banner<div className="font-semibold">{current.name}</div>
</div>
<div>
Thứ tự<div className="font-semibold">{current.sortOrder}</div>
</div>
<div>
Thời gian
<div className="font-semibold">
{current.displayTimeSeconds} giây
</div>
</div>
<div>
Trạng thái
<div>
<Badge variant="outline">
{current.isActive ? "Đang hiển thị" : "Đang ẩn"}
</Badge>
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
<ConfigItemDialog
open={dialogOpen}
mode="banner"
form={form}
previewMedia={selectedMedia}
saving={isCreatingBanner || isUpdatingBanner}
title={editingId ? "Chỉnh sửa banner" : "Thêm banner mới"}
description="Thiết lập banner hiển thị cho trang chủ."
onOpenChange={setDialogOpen}
onChange={(key, value) =>
setForm((previous) => ({ ...previous, [key]: value }))
}
onPickImage={() => setPickerOpen(true)}
onSubmit={save}
/>
<AdminImagePicker
open={pickerOpen}
selectedId={form.imageId}
onOpenChange={setPickerOpen}
onSelect={(item) => {
setSelectedMedia(item);
setForm((previous) => ({ ...previous, imageId: item.id }));
}}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa cấu hình"
description={
<>
Bạn có chắc muốn xóa{" "}
<span className="font-semibold">{deleteTarget?.name}</span>?
</>
}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={remove}
/>
</TabsContent>
);
}
import type { Logo } from "@/api/vcci-news/models";
import type { BaseConfigLogoItem } from "@/mockdata/base-config";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import type { LogoMediaItem } from "../../types";
import links from "@/links";
export function mapApiLogoToConfig(logo: Logo): {
logo: BaseConfigLogoItem;
media: LogoMediaItem;
} {
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 toLogoMediaItem(logo: Logo): AdminMediaItem {
return mapApiLogoToConfig(logo).media;
}
"use client";
import { useEffect, useMemo, useState } from "react";
import Image from "next/image";
import { Edit, ImagePlus, Plus, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { TabsContent } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { ConfigItemDialog } from "../config-item-dialog";
import {
useDeleteApiV10LogoId,
useGetApiV10Logo,
usePostApiV10Logo,
usePutApiV10LogoId,
} from "@/api/vcci-news/endpoints/logo";
import {
useGetApiV10SiteInformation,
usePutApiV10SiteInformation,
} from "@/api/vcci-news/endpoints/site-information";
import type { Logo } from "@/api/vcci-news/models";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { mapApiLogoToConfig } from "./_components/utils";
import {
fieldClassName,
type ConfigItemForm,
type LogoListResponse,
} from "../types";
export function BrandingTab() {
const emptyForm = (): ConfigItemForm => ({
name: "",
imageId: "",
isActive: true,
displayTimeSeconds: 5,
sortOrder: 1,
});
const {
data: logoData,
isLoading: isLogoLoading,
refetch: refetchLogo,
} = useGetApiV10Logo({
page: 1,
pageSize: 1,
sortField: "updated_at",
sortOrder: "desc",
});
const { data: siteData, isLoading: isSiteLoading } =
useGetApiV10SiteInformation();
const { mutateAsync: createLogo, isPending: isCreatingLogo } =
usePostApiV10Logo();
const { mutateAsync: updateLogo, isPending: isUpdatingLogo } =
usePutApiV10LogoId();
const { mutateAsync: deleteLogo } = useDeleteApiV10LogoId();
const { mutateAsync: updateSite, isPending: isUpdatingSite } =
usePutApiV10SiteInformation();
const logoResponse = logoData as
| {
responseData?: LogoListResponse;
data?: { responseData?: LogoListResponse };
}
| undefined;
const logo =
(logoResponse?.responseData ?? logoResponse?.data?.responseData)
?.rows?.[0] ?? null;
const siteResponse = siteData as
| {
responseData?: { website_name?: string; website_link?: string };
data?: {
responseData?: { website_name?: string; website_link?: string };
};
}
| undefined;
const site = siteResponse?.responseData ?? siteResponse?.data?.responseData;
const isLoading = isLogoLoading || isSiteLoading;
const logoConfig = useMemo(
() => (logo ? mapApiLogoToConfig(logo) : null),
[logo],
);
const [websiteName, setWebsiteName] = useState("");
const [websiteLink, setWebsiteLink] = useState("");
const [form, setForm] = useState<ConfigItemForm>(emptyForm());
const [dialogOpen, setDialogOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [selectedMedia, setSelectedMedia] = useState<AdminMediaItem | null>(
null,
);
const [saving, setSaving] = useState(false);
useEffect(() => {
setWebsiteName(site?.website_name ?? "");
setWebsiteLink(site?.website_link ?? "");
}, [site?.website_name, site?.website_link]);
useEffect(() => {
if (!logoConfig?.media) return;
setSelectedMedia(logoConfig.media);
}, [logoConfig]);
const currentMedia = selectedMedia ?? logoConfig?.media ?? null;
const handleSubmit = async () => {
if (!form.name.trim() || !form.imageId) {
toast.error(
!form.name.trim()
? "Vui lòng nhập tên hiển thị"
: "Vui lòng chọn hình ảnh",
);
return;
}
setSaving(true);
try {
const data = {
logo_name: form.name.trim(),
logo_url: currentMedia?.url ?? null,
file_id: form.imageId,
};
if (logo?.id) await updateLogo({ id: logo.id, data });
else await createLogo({ data });
setDialogOpen(false);
await refetchLogo();
toast.success("Đã lưu cấu hình logo");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Không thể lưu cấu hình logo",
);
} finally {
setSaving(false);
}
};
const openEdit = () => {
if (!logoConfig?.logo) return;
setForm({
name: logoConfig.logo.name,
imageId: logoConfig.logo.imageId,
isActive: true,
displayTimeSeconds: 5,
sortOrder: 1,
});
setDialogOpen(true);
};
const openCreate = () => {
setForm(emptyForm());
setDialogOpen(true);
};
const handleDelete = async () => {
if (!logo?.id) return;
try {
await deleteLogo({ id: logo.id });
setDeleteOpen(false);
await refetchLogo();
toast.success("Đã xóa cấu hình logo");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Không thể xóa cấu hình logo",
);
}
};
const handleSaveSite = async () => {
try {
await updateSite({
data: {
website_name: websiteName.trim() || null,
website_link: websiteLink.trim() || null,
},
});
toast.success("Đã lưu thông tin website");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Không thể lưu thông tin website",
);
}
};
return (
<TabsContent value="branding" className="mt-0" aria-busy={isLoading}>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">
Nhận diện thương hiệu
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý logo hiển thị trên website.
</CardDescription>
</div>
<div className="flex flex-wrap gap-3">
{logoConfig?.logo ? (
<>
<Button type="button" variant="outline" onClick={openEdit}>
<Edit className="mr-2 h-4 w-4" />
Cập nhật logo
</Button>
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(true)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</Button>
</>
) : (
<Button type="button" onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Thiết lập logo
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 px-4 sm:px-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_360px]">
<div className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
<div className="relative flex min-h-[320px] items-center justify-center overflow-hidden rounded-[24px] bg-[#eef4ff]">
{currentMedia ? (
<div className="relative h-[220px] w-[220px]">
<Image
src={currentMedia.url}
alt={currentMedia.alt || currentMedia.name}
fill
className="object-contain"
/>
</div>
) : (
<div className="text-center text-gray-500">
<ImagePlus className="mx-auto mb-3 h-10 w-10" />
Chưa có logo nào được cấu hình
</div>
)}
</div>
</div>
<div className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
{logoConfig?.logo ? (
<div className="space-y-4">
<div className="text-xs font-semibold uppercase text-[#4b74b8]">
Logo website
</div>
<div className="font-semibold text-[#163b73]">
{logoConfig.logo.name}
</div>
<div className="space-y-2">
<Label>Tên website</Label>
<Input
value={websiteName}
onChange={(event) => setWebsiteName(event.target.value)}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label>Link website</Label>
<Input
value={websiteLink}
onChange={(event) => setWebsiteLink(event.target.value)}
className={fieldClassName}
/>
</div>
<Button
type="button"
onClick={handleSaveSite}
disabled={isUpdatingSite}
className="w-full"
>
<Save className="mr-2 h-4 w-4" />
Lưu thông tin website
</Button>
</div>
) : (
<div className="py-8 text-center text-sm text-gray-500">
Chưa có logo nào, hãy thiết lập logo.
</div>
)}
</div>
</div>
</CardContent>
</Card>
<ConfigItemDialog
open={dialogOpen}
mode="logo"
form={form}
previewMedia={currentMedia}
saving={saving || isCreatingLogo || isUpdatingLogo}
title={logo ? "Cập nhật logo" : "Thiết lập logo"}
description="Thiết lập logo hiển thị cho website."
onOpenChange={setDialogOpen}
onChange={(key, value) =>
setForm((previous) => ({ ...previous, [key]: value }))
}
onPickImage={() => setPickerOpen(true)}
onSubmit={handleSubmit}
/>
<AdminImagePicker
open={pickerOpen}
selectedId={form.imageId}
onOpenChange={setPickerOpen}
onSelect={(item) => {
setSelectedMedia(item);
setForm((previous) => ({ ...previous, imageId: item.id }));
}}
/>
<AdminDeleteDialog
open={deleteOpen}
title="Xóa cấu hình"
description={
<>
Bạn có chắc muốn xóa{" "}
<span className="font-semibold">{logoConfig?.logo?.name}</span>?
</>
}
onOpenChange={setDeleteOpen}
onConfirm={handleDelete}
/>
</TabsContent>
);
}
...@@ -17,10 +17,7 @@ export function BranchCard({ ...@@ -17,10 +17,7 @@ export function BranchCard({
}) { }) {
return ( return (
<div <div
className={`rounded-3xl border p-4 transition-all ${current 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"}`}
? "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"> <button type="button" onClick={onSelect} className="w-full text-left">
<div className="text-sm font-semibold text-[#163b73]"> <div className="text-sm font-semibold text-[#163b73]">
...@@ -30,7 +27,6 @@ export function BranchCard({ ...@@ -30,7 +27,6 @@ export function BranchCard({
{branch.address || "Chưa cập nhật địa chỉ"} {branch.address || "Chưa cập nhật địa chỉ"}
</div> </div>
</button> </button>
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
{branch.hotline || "Chưa có hotline"} {branch.hotline || "Chưa có hotline"}
......
import type {
SiteInformationBranch,
SiteInformationBranchMutate,
} from "@/api/vcci-news/models";
import type { BaseConfigBranchItem } from "@/mockdata/base-config";
import { createBaseConfigItemId } from "@/mockdata/base-config";
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,
};
}
"use client";
import { useEffect, useMemo, useState } from "react";
import { Plus, Save } from "lucide-react";
import { toast } from "sonner";
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { BranchCard } from "./_components/BranchCard";
import { fieldClassName } from "../types";
import {
mapApiBranchToConfig,
mapConfigBranchToApi,
} from "./_components/utils";
import {
EMPTY_BASE_CONFIG_BRANCH,
createBaseConfigItemId,
sortBaseConfigBranches,
type BaseConfigBranchItem,
} from "@/mockdata/base-config";
import type { SiteInformationBranch } from "@/api/vcci-news/models";
import {
useDeleteApiV10SiteInformationBranchesId,
useGetApiV10SiteInformationBranches,
usePatchApiV10SiteInformationBranchesId,
usePostApiV10SiteInformationBranches,
} from "@/api/vcci-news/endpoints/site-information";
export function ContactTab() {
const {
data: branchesData,
isLoading: isBranchesLoading,
refetch: refetchBranches,
} = useGetApiV10SiteInformationBranches();
const { mutateAsync: createBranch } = usePostApiV10SiteInformationBranches();
const { mutateAsync: updateBranch } =
usePatchApiV10SiteInformationBranchesId();
const { mutateAsync: deleteBranch } =
useDeleteApiV10SiteInformationBranchesId();
const branchesResponse = branchesData as
| {
responseData?: SiteInformationBranch[];
data?: { responseData?: SiteInformationBranch[] };
}
| undefined;
const apiBranches =
branchesResponse?.responseData ??
branchesResponse?.data?.responseData ??
[];
const [branches, setBranches] = useState<BaseConfigBranchItem[]>([]);
const [currentId, setCurrentId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const current = useMemo(
() =>
branches.find((branch) => branch.id === currentId) ?? branches[0] ?? null,
[branches, currentId],
);
useEffect(() => {
if (!apiBranches.length) return;
const next = apiBranches.map(mapApiBranchToConfig);
setBranches(next);
setCurrentId((previous) =>
previous && next.some((branch) => branch.id === previous)
? previous
: (next[0]?.id ?? null),
);
}, [apiBranches]);
const change = <K extends keyof BaseConfigBranchItem>(
key: K,
value: BaseConfigBranchItem[K],
) => {
if (!current) return;
setBranches((previous) =>
previous.map((branch) =>
branch.id === current.id ? { ...branch, [key]: value } : branch,
),
);
};
const add = async () => {
setSaving(true);
try {
const response = await createBranch({
data: {
branch_name: `Chi nhánh ${branches.length + 1}`,
sort_order: branches.length + 1,
is_active: true,
},
});
const createdResponse = response as
| {
responseData?: SiteInformationBranch;
data?: { responseData?: SiteInformationBranch };
}
| undefined;
const created =
createdResponse?.responseData ?? createdResponse?.data?.responseData;
const next = created
? mapApiBranchToConfig(created)
: {
...EMPTY_BASE_CONFIG_BRANCH,
id: createBaseConfigItemId("branch"),
branchName: `Chi nhánh ${branches.length + 1}`,
sortOrder: branches.length + 1,
};
setBranches((previous) => [...previous, next]);
setCurrentId(next.id);
toast.success("Đã thêm chi nhánh mới");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Không thể thêm chi nhánh",
);
} finally {
setSaving(false);
}
};
const remove = async (id: string) => {
setSaving(true);
try {
await deleteBranch({ id });
const next = branches.filter((branch) => branch.id !== id);
setBranches(next);
setCurrentId(next[0]?.id ?? null);
toast.success("Đã xóa chi nhánh");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Không thể xóa chi nhánh",
);
} finally {
setSaving(false);
}
};
const save = async () => {
setSaving(true);
try {
await Promise.all(
sortBaseConfigBranches(branches).map((branch, index) =>
updateBranch({
id: branch.id,
data: mapConfigBranchToApi(branch, index),
}),
),
);
toast.success("Đã lưu danh sách chi nhánh");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Không thể lưu danh sách chi nhánh",
);
} finally {
setSaving(false);
}
};
return (
<TabsContent value="contact" className="mt-0" aria-busy={isBranchesLoading}>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">
Thông tin liên hệ website
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý nhiều địa chỉ chi nhánh.
</CardDescription>
</div>
<div className="flex gap-3">
<Button type="button" onClick={add}>
<Plus className="mr-2 h-4 w-4" />
Thêm chi nhánh
</Button>
<Button type="button" onClick={save} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? "Đang lưu..." : "Lưu danh sách chi nhánh"}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-6 px-4 sm:px-6 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
<div className="text-sm font-semibold uppercase text-[#4b74b8]">
Danh sách chi nhánh
</div>
{branches.map((branch) => (
<BranchCard
key={branch.id}
branch={branch}
current={current?.id === branch.id}
onSelect={() => setCurrentId(branch.id)}
onDelete={() => remove(branch.id)}
/>
))}
</div>
<div className="space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
{current ? (
<>
<div className="space-y-2">
<Label>Tên chi nhánh</Label>
<Input
value={current.branchName}
onChange={(event) =>
change("branchName", event.target.value)
}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label>Địa chỉ</Label>
<Input
value={current.address}
onChange={(event) => change("address", event.target.value)}
className={fieldClassName}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div>
<Label>Hotline</Label>
<Input
value={current.hotline}
onChange={(event) =>
change("hotline", event.target.value)
}
className={fieldClassName}
/>
</div>
<div>
<Label>Email</Label>
<Input
value={current.email}
onChange={(event) => change("email", event.target.value)}
className={fieldClassName}
/>
</div>
<div>
<Label>Fax</Label>
<Input
value={current.fax}
onChange={(event) => change("fax", event.target.value)}
className={fieldClassName}
/>
</div>
<div>
<Label>Google Maps</Label>
<Input
value={current.mapsEmbedUrl}
onChange={(event) =>
change("mapsEmbedUrl", event.target.value)
}
className={fieldClassName}
/>
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div>
<Label>Thứ tự hiển thị</Label>
<Input
type="number"
value={current.sortOrder}
onChange={(event) =>
change("sortOrder", Number(event.target.value || 1))
}
className={fieldClassName}
/>
</div>
<div className="flex items-center justify-between rounded-2xl border bg-white px-4 py-3">
<div>
<div className="font-medium">Trạng thái hiển thị</div>
<div className="text-xs text-gray-500">
{current.isVisible ? "Đang hiển thị" : "Đang ẩn"}
</div>
</div>
<Switch
checked={current.isVisible}
onCheckedChange={(value) => change("isVisible", value)}
/>
</div>
</div>
</>
) : (
<div className="py-10 text-center text-sm text-gray-500">
Chưa có chi nhánh nào.
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
);
}
import type {
SiteInformationSocialLink,
SiteInformationSocialMutate,
} from "@/api/vcci-news/models";
import type { BaseConfigSocialItem } from "@/mockdata/base-config";
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,
};
}
"use client";
import { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
useGetApiV10SiteInformationSocials,
usePatchApiV10SiteInformationSocialsId,
} from "@/api/vcci-news/endpoints/site-information";
import type { SiteInformationSocialLink } from "@/api/vcci-news/models";
import type { BaseConfigSocialItem } from "@/mockdata/base-config";
import {
mapConfigSocialToApi,
mapApiSocialToConfig,
} from "./_components/utils";
import { fieldClassName } from "../types";
export function SocialTab() {
const { data: socialsData, isLoading: isSocialsLoading } =
useGetApiV10SiteInformationSocials();
const { mutateAsync: updateSocial, isPending: isUpdatingSocials } =
usePatchApiV10SiteInformationSocialsId();
const [socials, setSocials] = useState<BaseConfigSocialItem[]>([]);
const socialsResponse = socialsData as
| {
responseData?: SiteInformationSocialLink[];
data?: { responseData?: SiteInformationSocialLink[] };
}
| undefined;
const apiSocials =
socialsResponse?.responseData ?? socialsResponse?.data?.responseData ?? [];
useEffect(() => {
if (apiSocials.length) setSocials(apiSocials.map(mapApiSocialToConfig));
}, [apiSocials]);
const change = <K extends keyof BaseConfigSocialItem>(
id: string,
key: K,
value: BaseConfigSocialItem[K],
) => {
setSocials((previous) =>
previous.map((social) =>
social.id === id ? { ...social, [key]: value } : social,
),
);
};
const save = async () => {
try {
await Promise.all(
socials.map((social) =>
updateSocial({
id: social.id,
data: mapConfigSocialToApi(social),
}),
),
);
toast.success("Đã lưu cấu hình mạng xã hội");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Không thể lưu cấu hình mạng xã hội",
);
}
};
return (
<TabsContent value="social" className="mt-0" aria-busy={isSocialsLoading}>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">
Mạng xã hội
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý link mạng xã hội và thứ tự hiển thị.
</CardDescription>
</div>
<Button type="button" onClick={save} disabled={isUpdatingSocials}>
<Save className="mr-2 h-4 w-4" />
{isUpdatingSocials ? "Đang lưu..." : "Lưu cấu hình"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 sm:px-6">
{socials.map((item) => (
<div
key={item.id}
className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end">
<div className="flex items-center gap-3 rounded-2xl border bg-white px-4 py-4">
<Checkbox
checked={item.isVisible}
onCheckedChange={(checked) =>
change(item.id, "isVisible", checked === true)
}
/>
<div>
<div className="font-semibold">{item.label}</div>
<div className="text-sm text-slate-500">
{item.isVisible ? "Đang hiển thị" : "Đang ẩn"}
</div>
</div>
</div>
<div className="space-y-2">
<Label>Link URL</Label>
<Input
value={item.url}
onChange={(event) =>
change(item.id, "url", event.target.value)
}
placeholder={`Nhập link ${item.label}...`}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label>Thứ tự hiển thị</Label>
<Input
type="number"
min={1}
value={item.sortOrder}
onChange={(event) =>
change(
item.id,
"sortOrder",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
);
}
"use client"; "use client";
import { ImagePlus, Save } from "lucide-react"; import { ImagePlus, Save } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
...@@ -15,7 +15,11 @@ import { Input } from "@/components/ui/input"; ...@@ -15,7 +15,11 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { AdminMediaItem } from "@/mockdata/admin-news"; import type { AdminMediaItem } from "@/mockdata/admin-news";
import { fieldClassName, type ConfigItemForm, type ConfigItemMode } from "./types"; import {
fieldClassName,
type ConfigItemForm,
type ConfigItemMode,
} from "./types";
export function ConfigItemDialog({ export function ConfigItemDialog({
open, open,
...@@ -114,7 +118,7 @@ export function ConfigItemDialog({ ...@@ -114,7 +118,7 @@ export function ConfigItemDialog({
<div className="overflow-hidden rounded-3xl border border-dashed border-[#063e8e]/20 bg-[#eef4ff]/60"> <div className="overflow-hidden rounded-3xl border border-dashed border-[#063e8e]/20 bg-[#eef4ff]/60">
<div className="relative aspect-[16/9]"> <div className="relative aspect-[16/9]">
{previewMedia ? ( {previewMedia ? (
<SafeImage <Image
src={previewMedia.url} src={previewMedia.url}
alt={previewMedia.alt || previewMedia.name} alt={previewMedia.alt || previewMedia.name}
fill fill
......
...@@ -5,13 +5,6 @@ export const fieldClassName = ...@@ -5,13 +5,6 @@ export const fieldClassName =
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"; "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 ConfigItemMode = "logo" | "banner";
export type ApiEnvelope<T> = {
responseData?: T;
data?: {
responseData?: T;
};
};
export type LogoMediaItem = AdminMediaItem & { export type LogoMediaItem = AdminMediaItem & {
logoId?: string; logoId?: string;
}; };
......
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,
};
}
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import { BrandingTab } from "./_components/BrandingTab";
ChevronLeft, import { BannerTab } from "./_components/BannerTab";
ChevronRight, import { ContactTab } from "./_components/ContactTab";
Edit, import { SocialTab } from "./_components/SocialTab";
ImagePlus,
Plus,
Save,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { SafeImage } from "@/components/shared/safe-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
deleteApiV10SiteInformationBranchesId,
getApiV10SiteInformation,
patchApiV10SiteInformationBranchesId,
patchApiV10SiteInformationSocialsId,
postApiV10SiteInformationBranches,
putApiV10SiteInformation,
} from "@/api/vcci-news/endpoints/site-information";
import {
deleteApiV10LogoId,
getApiV10Logo,
getApiV10LogoId,
postApiV10Logo,
putApiV10LogoId,
} from "@/api/vcci-news/endpoints/logo";
import {
deleteApiV10BannerId,
getApiV10Banner,
postApiV10Banner,
putApiV10BannerId,
} from "@/api/vcci-news/endpoints/banner";
import type {
Banner,
Logo,
SiteInformationBranch,
SiteInformationData,
} from "@/api/vcci-news/models";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { toAdminMediaItem } from "@/lib/utils/file";
import { getApiV10FileId } from "@/api/vcci-news/endpoints/file";
import {
type BaseConfigBannerItem,
type BaseConfigBranchItem,
type BaseConfigData,
type BaseConfigLogoItem,
type BaseConfigSocialItem,
EMPTY_BASE_CONFIG_BRANCH,
cloneBaseConfigData,
createBaseConfigItemId,
persistBaseConfig,
readBaseConfig,
sortBaseConfigBanners,
sortBaseConfigBranches,
sortBaseConfigSocials,
} from "@/mockdata/base-config";
import { BranchCard } from "./_components/branch-card";
import { ConfigItemDialog } from "./_components/config-item-dialog";
import { ConfigItemPreview } from "./_components/config-item-preview";
import {
fieldClassName,
type ConfigItemForm,
type ConfigItemMode,
type LogoListResponse,
type PageEnvelope,
} from "./_components/types";
import {
applySiteInformationToConfig,
emptyItemForm,
getEnvelopeData,
mapApiBannerToConfig,
mapApiBranchToConfig,
mapApiLogoToConfig,
mapConfigBannerToApi,
mapConfigBranchToApi,
mapConfigSocialToApi,
resolveMediaItem,
} from "./_components/utils";
export default function AdminBaseConfigPage() { export default function AdminBaseConfigPage() {
const queryClient = useQueryClient();
const [config, setConfig] = useState<BaseConfigData | null>(null);
const [mediaItems, setMediaItems] = useState<AdminMediaItem[]>([]);
const [currentBannerIndex, setCurrentBannerIndex] = useState(0);
const [currentBranchIndex, setCurrentBranchIndex] = useState(0);
const [currentBranchId, setCurrentBranchId] = useState<string | null>(
null,
);
const [activeTab, setActiveTab] = useState("branding"); const [activeTab, setActiveTab] = useState("branding");
const [itemDialogOpen, setItemDialogOpen] = useState(false);
const [itemDialogMode, setItemDialogMode] =
useState<ConfigItemMode>("logo");
const [editingItemId, setEditingItemId] = useState<string | null>(null);
const [itemForm, setItemForm] =
useState<ConfigItemForm>(emptyItemForm());
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [savingItem, setSavingItem] = useState(false);
const [savingWebsiteInfo, setSavingWebsiteInfo] = useState(false);
const [savingContact, setSavingContact] = useState(false);
const [savingSocials, setSavingSocials] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{
mode: ConfigItemMode;
id: string;
name: string;
} | null>(null);
useEffect(() => {
let mounted = true;
const baseConfig = readBaseConfig();
setConfig(baseConfig);
const loadSiteInformation = async () => {
try {
const [siteInformationResponse, logoResponse] = await Promise.all([
getApiV10SiteInformation(),
getApiV10Logo({
page: 1,
pageSize: 1,
sortField: "updated_at",
sortOrder: "desc",
}),
]);
const siteInformation = getEnvelopeData<SiteInformationData>(
siteInformationResponse,
);
const logoPage = getEnvelopeData<LogoListResponse>(logoResponse);
const currentLogo = logoPage?.rows?.[0] ?? null;
if (!mounted) return;
if (currentLogo) {
const logoConfig = mapApiLogoToConfig(currentLogo);
if (logoConfig?.media) {
const logoMedia = logoConfig.media;
setMediaItems((previous) => {
const nextMap = new Map(
previous.map((entry) => [entry.id, entry]),
);
nextMap.set(logoMedia.id, logoMedia);
return Array.from(nextMap.values());
});
}
}
if (siteInformation) {
setConfig((previous) =>
applySiteInformationToConfig(
previous ?? baseConfig,
siteInformation,
currentLogo,
),
);
return;
}
if (currentLogo) {
const logoConfig = mapApiLogoToConfig(currentLogo);
setConfig((previous) =>
previous
? {
...previous,
logo: logoConfig?.logo ?? previous.logo,
}
: {
...baseConfig,
logo: logoConfig?.logo ?? baseConfig.logo,
},
);
}
} catch (error) {
console.error(error);
if (mounted) {
toast.error("Không thể tải thông tin liên hệ website");
}
}
};
void loadSiteInformation();
const loadBanners = async () => {
try {
const response = await getApiV10Banner({
page: 1,
pageSize: 100,
sortField: "display_order",
sortOrder: "asc",
});
const pageData = getEnvelopeData<PageEnvelope<Banner>>(response);
const bannerRows = pageData?.rows ?? [];
const mediaRows = await Promise.all(
bannerRows
.map((banner) => banner.file_id)
.filter((fileId): fileId is string => Boolean(fileId))
.map(async (fileId) => {
const response = await getApiV10FileId(fileId).catch(() => null);
const file = response?.responseData ?? null;
return file ? toAdminMediaItem(file) : null;
}),
);
if (!mounted) return;
const nextBanners = bannerRows.map(mapApiBannerToConfig);
setMediaItems((previous) => {
const nextMap = new Map(previous.map((entry) => [entry.id, entry]));
mediaRows.forEach((item) => {
if (item) nextMap.set(item.id, item);
});
return Array.from(nextMap.values());
});
setConfig((previous) =>
previous
? {
...previous,
banners: nextBanners,
}
: previous,
);
setCurrentBannerIndex(0);
} catch (error) {
console.error(error);
if (mounted) {
toast.error("Không thể tải danh sách banner");
}
}
};
void loadBanners();
return () => {
mounted = false;
};
}, []);
const mediaMap = useMemo(
() => new Map(mediaItems.map((item) => [item.id, item])),
[mediaItems],
);
const sortedBanners = useMemo(
() => (config ? sortBaseConfigBanners(config.banners) : []),
[config],
);
const sortedSocials = useMemo(
() => (config ? sortBaseConfigSocials(config.socials) : []),
[config],
);
const sortedBranches = useMemo(
() => (config ? sortBaseConfigBranches(config.branches) : []),
[config],
);
const currentLogo = config?.logo ?? null;
const currentBanner = sortedBanners[currentBannerIndex] ?? null;
const currentBranch =
(currentBranchId
? sortedBranches.find((branch) => branch.id === currentBranchId)
: null) ??
sortedBranches[currentBranchIndex] ??
null;
const currentLogoMedia = currentLogo
? resolveMediaItem(mediaMap, currentLogo.imageId)
: null;
const currentBannerMedia = currentBanner
? resolveMediaItem(mediaMap, currentBanner.imageId)
: null;
const previewMedia = resolveMediaItem(mediaMap, itemForm.imageId);
const saveConfig = useCallback((nextConfig: BaseConfigData) => {
setConfig(nextConfig);
persistBaseConfig(nextConfig);
}, []);
const openCreateDialog = (mode: ConfigItemMode) => {
setItemDialogMode(mode);
setEditingItemId(null);
setItemForm({
...emptyItemForm(),
sortOrder:
mode === "banner" ? (config ? config.banners.length + 1 : 1) : 1,
});
setItemDialogOpen(true);
};
const openEditDialog = (
mode: ConfigItemMode,
item: BaseConfigLogoItem | BaseConfigBannerItem,
) => {
setItemDialogMode(mode);
setEditingItemId(item.id);
setItemForm({
name: item.name,
imageId: item.imageId,
isActive: item.isActive,
displayTimeSeconds:
"displayTimeSeconds" in item ? item.displayTimeSeconds : 5,
sortOrder: "sortOrder" in item ? item.sortOrder : 1,
});
setItemDialogOpen(true);
};
const handleSubmitItem = async () => {
if (!config) return;
const trimmedName = itemForm.name.trim();
if (!trimmedName) {
toast.error("Vui lòng nhập tên hiển thị");
return;
}
if (!itemForm.imageId) {
toast.error("Vui lòng chọn hình ảnh");
return;
}
setSavingItem(true);
if (itemDialogMode === "logo") {
const selectedMedia = resolveMediaItem(mediaMap, itemForm.imageId);
const currentLogoId = editingItemId || currentLogo?.id || null;
try {
let apiLogoId: string | null = null;
// If we have a currentLogoId, resolve it to actual UUID
if (currentLogoId) {
try {
const logoResponse = await getApiV10LogoId(currentLogoId);
const existingLogo = getEnvelopeData<Logo>(logoResponse);
// Only use if it's a valid UUID (32+ chars with hyphens)
if (existingLogo?.id && existingLogo.id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
apiLogoId = existingLogo.id;
}
} catch {
// currentLogoId is not a valid UUID or fetch failed, treat as create new
apiLogoId = null;
}
}
const response = apiLogoId
? await putApiV10LogoId(apiLogoId, {
logo_name: trimmedName,
logo_url: selectedMedia?.url ?? null,
file_id: itemForm.imageId,
})
: await postApiV10Logo({
logo_name: trimmedName,
logo_url: selectedMedia?.url ?? null,
file_id: itemForm.imageId,
});
const savedLogo =
getEnvelopeData<Logo>(response);
const nextConfig = cloneBaseConfigData(config);
nextConfig.logo = {
id: savedLogo?.id ?? apiLogoId ?? createBaseConfigItemId("logo"),
name: savedLogo?.logo_name ?? trimmedName,
imageId: savedLogo?.file_id ?? itemForm.imageId,
isActive: true,
};
saveConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ["/logo"] });
toast.success("Đã lưu cấu hình logo");
} catch (error) {
console.error(error);
setSavingItem(false);
toast.error("Không thể lưu cấu hình logo");
}
return;
}
const bannerDraft: BaseConfigBannerItem = {
id: editingItemId || createBaseConfigItemId("banner"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
};
try {
const response = editingItemId
? await putApiV10BannerId(editingItemId, mapConfigBannerToApi(bannerDraft))
: await postApiV10Banner(mapConfigBannerToApi(bannerDraft));
const savedBanner = getEnvelopeData<Banner>(response);
const nextBanner = savedBanner
? mapApiBannerToConfig(savedBanner)
: bannerDraft;
const nextConfig = cloneBaseConfigData(config);
if (editingItemId) {
nextConfig.banners = nextConfig.banners.map((item) =>
item.id === editingItemId ? nextBanner : item,
);
} else {
nextConfig.banners.push(nextBanner);
setCurrentBannerIndex(Math.max(nextConfig.banners.length - 1, 0));
}
setConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
toast.success("Đã lưu cấu hình banner");
} catch (error) {
console.error(error);
setSavingItem(false);
toast.error("Không thể lưu cấu hình banner");
}
return;
const nextConfig = cloneBaseConfigData(config!);
if (editingItemId) {
nextConfig.banners = nextConfig.banners.map((item) =>
item.id === editingItemId
? {
...item,
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
}
: item,
);
} else {
nextConfig.banners.push({
id: createBaseConfigItemId("banner"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
});
setCurrentBannerIndex(Math.max(nextConfig.banners.length - 1, 0));
}
saveConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
toast.success(false ? "Đã lưu cấu hình logo" : "Đã lưu cấu hình banner");
};
const handleDeleteItem = async () => {
if (!config || !deleteTarget) return;
try {
const nextConfig = cloneBaseConfigData(config);
if (deleteTarget.mode === "logo") {
try {
await deleteApiV10LogoId(deleteTarget.id);
nextConfig.logo = null;
queryClient.invalidateQueries({ queryKey: ["/logo"] });
} catch (error) {
console.error(error);
toast.error("Không thể xóa cấu hình logo");
setDeleteTarget(null);
return;
}
} else {
await deleteApiV10BannerId(deleteTarget.id);
nextConfig.banners = nextConfig.banners.filter(
(item) => item.id !== deleteTarget.id,
);
setCurrentBannerIndex((previous) =>
Math.max(0, Math.min(previous, nextConfig.banners.length - 1)),
);
}
saveConfig(nextConfig);
toast.success("Đã xóa cấu hình");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Không thể xóa cấu hình",
);
} finally {
setDeleteTarget(null);
}
};
const handleBranchChange = <K extends keyof BaseConfigBranchItem>(
key: K,
value: BaseConfigBranchItem[K],
) => {
if (!config || !currentBranch) return;
setConfig((previous) =>
previous
? {
...previous,
branches: previous.branches.map((branch) =>
branch.id === currentBranch.id
? { ...branch, [key]: value }
: branch,
),
}
: previous,
);
};
const handleAddBranch = () => {
if (!config) return;
const nextBranch: BaseConfigBranchItem = {
...EMPTY_BASE_CONFIG_BRANCH,
id: createBaseConfigItemId("branch"),
branchName: `Chi nhánh ${config.branches.length + 1}`,
};
const nextConfig = cloneBaseConfigData(config);
nextConfig.branches.push(nextBranch);
saveConfig(nextConfig);
setCurrentBranchIndex(nextConfig.branches.length - 1);
toast.success("Đã thêm chi nhánh mới");
};
const handleDeleteBranch = (branchId: string) => {
if (!config) return;
const nextConfig = cloneBaseConfigData(config);
nextConfig.branches = nextConfig.branches.filter(
(branch) => branch.id !== branchId,
);
saveConfig(nextConfig);
setCurrentBranchIndex((previous) =>
Math.max(
0,
Math.min(previous, Math.max(nextConfig.branches.length - 1, 0)),
),
);
toast.success("Đã xóa chi nhánh");
};
const handleSaveBranches = () => {
if (!config) return;
setSavingContact(true);
persistBaseConfig(config);
setSavingContact(false);
toast.success("Đã lưu danh sách chi nhánh liên hệ");
};
const handleAddBranchApi = async () => {
if (!config) return;
setSavingContact(true);
try {
const response = await postApiV10SiteInformationBranches({
branch_name: `Chi nhánh ${config.branches.length + 1}`,
sort_order: config.branches.length + 1,
is_active: true,
});
const createdBranch = getEnvelopeData<SiteInformationBranch>(response);
const nextBranch = createdBranch
? mapApiBranchToConfig(createdBranch)
: {
id: createBaseConfigItemId("branch"),
branchName: `Chi nhánh ${config.branches.length + 1}`,
address: "",
hotline: "",
email: "",
fax: "",
mapsEmbedUrl: "",
sortOrder: config.branches.length + 1,
isVisible: true,
};
const nextConfig = cloneBaseConfigData(config);
nextConfig.branches.push(nextBranch);
setConfig(nextConfig);
setCurrentBranchIndex(nextConfig.branches.length - 1);
setCurrentBranchId(nextBranch.id);
toast.success("Đã thêm chi nhánh mới");
} catch (error) {
console.error(error);
toast.error("Không thể thêm chi nhánh");
} finally {
setSavingContact(false);
}
};
const handleDeleteBranchApi = async (branchId: string) => {
if (!config) return;
setSavingContact(true);
try {
await deleteApiV10SiteInformationBranchesId(branchId);
const nextConfig = cloneBaseConfigData(config);
nextConfig.branches = nextConfig.branches.filter(
(branch) => branch.id !== branchId,
);
setConfig(nextConfig);
setCurrentBranchIndex((previous) =>
Math.max(
0,
Math.min(previous, Math.max(nextConfig.branches.length - 1, 0)),
),
);
setCurrentBranchId(null);
toast.success("Đã xóa chi nhánh");
} catch (error) {
console.error(error);
toast.error("Không thể xóa chi nhánh");
} finally {
setSavingContact(false);
}
};
const handleSaveBranchesApi = async () => {
if (!config) return;
setSavingContact(true);
try {
await Promise.all(
sortBaseConfigBranches(config.branches).map((branch, index) =>
patchApiV10SiteInformationBranchesId(
branch.id,
mapConfigBranchToApi(branch, index),
),
),
);
setConfig(config);
toast.success("Đã lưu danh sách chi nhánh liên hệ");
} catch (error) {
console.error(error);
toast.error("Không thể lưu danh sách chi nhánh");
} finally {
setSavingContact(false);
}
};
const handleWebsiteInfoChange = (
key: "websiteName" | "websiteLink",
value: string,
) => {
setConfig((previous) =>
previous ? { ...previous, [key]: value } : previous,
);
};
const handleSaveWebsiteInfo = async () => {
if (!config) return;
setSavingWebsiteInfo(true);
try {
const response = await putApiV10SiteInformation({
website_name: config.websiteName.trim() || null,
website_link: config.websiteLink.trim() || null,
});
const siteInformation = getEnvelopeData<SiteInformationData>(response);
if (siteInformation) {
setConfig((previous) =>
applySiteInformationToConfig(previous ?? config, siteInformation),
);
} else {
setConfig(config);
}
toast.success("Đã lưu thông tin website");
} catch (error) {
console.error(error);
toast.error("Không thể lưu thông tin website");
} finally {
setSavingWebsiteInfo(false);
}
};
const handleSocialChange = <K extends keyof BaseConfigSocialItem>(
socialId: string,
key: K,
value: BaseConfigSocialItem[K],
) => {
setConfig((previous) =>
previous
? {
...previous,
socials: previous.socials.map((item) =>
item.id === socialId ? { ...item, [key]: value } : item,
),
}
: previous,
);
};
const handleSaveSocials = () => {
if (!config) return;
saveConfig(config);
toast.success("Đã lưu cấu hình mạng xã hội");
};
const handleSaveSocialsApi = async () => {
if (!config) return;
setSavingSocials(true);
try {
await Promise.all(
config.socials.map((social) =>
patchApiV10SiteInformationSocialsId(
social.id,
mapConfigSocialToApi(social),
),
),
);
setConfig(config);
toast.success("Đã lưu cấu hình mạng xã hội");
} catch (error) {
console.error(error);
toast.error("Không thể lưu cấu hình mạng xã hội");
} finally {
setSavingSocials(false);
}
};
if (!config) {
return (
<div className="rounded-3xl border border-[#063e8e]/10 bg-white p-10 text-center text-gray-500">
Đang tải cấu hình chung...
</div>
);
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<Tabs <Tabs className="space-y-5" value={activeTab} onValueChange={setActiveTab}>
value={activeTab}
onValueChange={setActiveTab}
className="space-y-5"
>
<div className="overflow-x-auto pb-1"> <div className="overflow-x-auto pb-1">
<TabsList className="h-auto min-w-max rounded-2xl bg-[#eaf2ff] p-1.5"> <TabsList className="h-auto min-w-max rounded-2xl bg-[#eaf2ff] p-1.5">
<TabsTrigger <TabsTrigger
...@@ -783,649 +41,13 @@ export default function AdminBaseConfigPage() { ...@@ -783,649 +41,13 @@ export default function AdminBaseConfigPage() {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<div className="space-y-5">
<TabsContent value="branding" className="mt-0"> <BrandingTab />
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm"> <BannerTab />
<CardHeader className="pb-5"> <ContactTab />
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <SocialTab />
<div>
<CardTitle className="text-2xl text-[#163b73]">
Nhận diện thương hiệu
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý logo hiển thị trên website.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
{currentLogo ? (
<>
<Button
type="button"
variant="outline"
onClick={() => openEditDialog("logo", currentLogo)}
className="rounded-xl border-[#063e8e]/15 text-gray-700"
>
<Edit className="mr-2 h-4 w-4" />
Cập nhật logo
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setDeleteTarget({
mode: "logo",
id: currentLogo.id,
name: currentLogo.name,
})
}
className="rounded-xl border-red-200 text-red-600 hover:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</Button>
</>
) : (
<Button
type="button"
onClick={() => openCreateDialog("logo")}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Thiết lập logo
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 px-4 sm:px-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_360px]">
<div className="rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#f8fbff] to-white p-4 sm:p-5">
<div className="relative flex min-h-[320px] items-center justify-center overflow-hidden rounded-[24px] border border-dashed border-[#063e8e]/18 bg-[#eef4ff]">
{currentLogoMedia ? (
<div className="relative h-[220px] w-[220px]">
<SafeImage
src={currentLogoMedia.url}
alt={currentLogoMedia.alt || currentLogoMedia.name}
fill
className="object-contain"
/>
</div> </div>
) : (
<div className="text-center text-gray-500">
<ImagePlus className="mx-auto mb-3 h-10 w-10 text-[#4b74b8]" />
Chưa có logo nào được cấu hình
</div>
)}
</div>
</div>
<div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5">
{currentLogo ? (
<div className="space-y-4 rounded-3xl border border-[#063e8e]/12 bg-white p-5 text-sm text-slate-600 shadow-sm">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-[#4b74b8]">
Logo website
</div>
<div className="mt-3 font-semibold text-[#163b73]">
{currentLogo.name}
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Tên website</Label>
<Input
value={config.websiteName}
onChange={(event) =>
handleWebsiteInfoChange(
"websiteName",
event.target.value,
)
}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Link website</Label>
<Input
value={config.websiteLink}
onChange={(event) =>
handleWebsiteInfoChange(
"websiteLink",
event.target.value,
)
}
className={fieldClassName}
/>
</div>
<div className="hidden rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Trạng thái
</div>
<div className="mt-2 flex items-center gap-2">
<Badge
variant="outline"
className="border-[#063e8e]/20 text-[#063e8e]"
>
{currentLogo.isActive ? "Đang hiển thị" : "Đang ẩn"}
</Badge>
</div>
</div>
<Button
type="button"
onClick={handleSaveWebsiteInfo}
disabled={savingWebsiteInfo}
className="w-full rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
{savingWebsiteInfo
? "Đang lưu..."
: "Lưu thông tin website"}
</Button>
</div>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-8 text-center text-sm text-gray-500">
Chưa có logo nào, hãy thiết lập logo cho website.
</div>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="banner" className="mt-0">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">
Banner trang chủ
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý hình ảnh slider chỉ dùng cho khu vực banner trang
chủ của website.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={() => openCreateDialog("banner")}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Thêm banner
</Button>
{currentBanner ? (
<>
<Button
type="button"
variant="outline"
onClick={() => openEditDialog("banner", currentBanner)}
className="rounded-xl border-[#063e8e]/15 text-gray-700"
>
<Edit className="mr-2 h-4 w-4" />
Sửa
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setDeleteTarget({
mode: "banner",
id: currentBanner.id,
name: currentBanner.name,
})
}
className="rounded-xl border-red-200 text-red-600 hover:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</Button>
</>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 px-4 sm:px-6">
<div className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5">
<div className="relative aspect-[16/6] overflow-hidden rounded-[24px] border border-[#063e8e]/12 bg-[#eef4ff]">
{currentBannerMedia ? (
<SafeImage
src={currentBannerMedia.url}
alt={currentBannerMedia.alt || currentBannerMedia.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
Chưa có banner được chọn
</div>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm font-semibold uppercase tracking-[0.15em] text-[#4b74b8]">
Danh sách banner trang chủ
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="rounded-xl border-[#063e8e]/15"
onClick={() =>
setCurrentBannerIndex((previous) =>
previous <= 0
? Math.max(sortedBanners.length - 1, 0)
: previous - 1,
)
}
disabled={sortedBanners.length <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="rounded-xl border-[#063e8e]/15"
onClick={() =>
setCurrentBannerIndex((previous) =>
sortedBanners.length === 0
? 0
: (previous + 1) % sortedBanners.length,
)
}
disabled={sortedBanners.length <= 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{sortedBanners.map((item, index) => (
<ConfigItemPreview
key={item.id}
title={`Banner ${index + 1}`}
item={item}
media={resolveMediaItem(mediaMap, item.imageId)}
current={index === currentBannerIndex}
onSelect={() => setCurrentBannerIndex(index)}
/>
))}
</div>
{currentBanner ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Tên banner
</div>
<div className="mt-2 font-semibold text-[#163b73]">
{currentBanner.name}
</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Thứ tự hiển thị
</div>
<div className="mt-2 font-semibold text-[#163b73]">
{currentBanner.sortOrder}
</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Thời gian hiển thị
</div>
<div className="mt-2 font-semibold text-[#163b73]">
{currentBanner.displayTimeSeconds} giây
</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Trạng thái
</div>
<div className="mt-2">
<Badge
variant="outline"
className="border-[#063e8e]/20 text-[#063e8e]"
>
{currentBanner.isActive ? "Đang hiển thị" : "Đang ẩn"}
</Badge>
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="contact" className="mt-0">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">
Thông tin liên hệ website
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý nhiều địa chỉ chi nhánh để hiển thị trên website.
</CardDescription>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="button"
onClick={handleAddBranchApi}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Thêm chi nhánh
</Button>
<Button
type="button"
onClick={handleSaveBranchesApi}
disabled={savingContact}
className="rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
{savingContact ? "Đang lưu..." : "Lưu danh sách chi nhánh"}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-6 px-4 sm:px-6 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5">
<div className="text-sm font-semibold uppercase tracking-[0.15em] text-[#4b74b8]">
Danh sách chi nhánh
</div>
<div className="space-y-3">
{sortedBranches.map((branch, index) => (
<BranchCard
key={branch.id}
branch={branch}
current={currentBranch?.id === branch.id}
onSelect={() => {
setCurrentBranchIndex(index);
setCurrentBranchId(branch.id);
}}
onDelete={() => handleDeleteBranchApi(branch.id)}
/>
))}
</div>
</div>
<div className="space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5">
{currentBranch ? (
<>
<div className="space-y-2">
<Label className="text-gray-700">Tên chi nhánh</Label>
<Input
value={currentBranch.branchName}
onChange={(event) =>
handleBranchChange("branchName", event.target.value)
}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Địa chỉ</Label>
<Textarea
value={currentBranch.address}
onChange={(event) =>
handleBranchChange("address", event.target.value)
}
className={`${fieldClassName} min-h-[110px]`}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-gray-700">Hotline</Label>
<Input
value={currentBranch.hotline}
onChange={(event) =>
handleBranchChange("hotline", event.target.value)
}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Email</Label>
<Input
value={currentBranch.email}
onChange={(event) =>
handleBranchChange("email", event.target.value)
}
className={fieldClassName}
/>
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-gray-700">Hotline 2</Label>
<Input
value={currentBranch.fax}
onChange={(event) =>
handleBranchChange("fax", event.target.value)
}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Google Maps</Label>
<Input
value={currentBranch.mapsEmbedUrl}
onChange={(event) =>
handleBranchChange(
"mapsEmbedUrl",
event.target.value,
)
}
className={fieldClassName}
/>
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-gray-700">Thứ tự hiển thị</Label>
<Input
type="number"
min={1}
value={currentBranch.sortOrder}
onChange={(event) =>
handleBranchChange(
"sortOrder",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
</div>
<div className="flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-white 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">
{currentBranch.isVisible
? "Đang hiển thị"
: "Đang ẩn"}
</div>
</div>
<Switch
checked={currentBranch.isVisible}
onCheckedChange={(value) =>
handleBranchChange("isVisible", value)
}
/>
</div>
</div>
</>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-10 text-center text-sm text-gray-500">
Chưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu
hình
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="social" className="mt-0">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">
Mạng xã hội
</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý link mạng xã hội và thứ tự hiển thị trên website.
</CardDescription>
</div>
<Button
type="button"
onClick={handleSaveSocialsApi}
disabled={savingSocials}
className="rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
Lưu cấu hình
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 sm:px-6">
{sortedSocials.map((item) => (
<div
key={item.id}
className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 sm:p-5"
>
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end">
<div className="flex items-center gap-3 rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<Checkbox
checked={item.isVisible}
onCheckedChange={(checked) =>
handleSocialChange(
item.id,
"isVisible",
checked === true,
)
}
/>
<div>
<div className="font-semibold text-[#163b73]">
{item.label}
</div>
<div className="text-sm text-slate-500">
{item.isVisible ? "Đang hiển thị" : "Đang ẩn"}
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Link URL</Label>
<Input
value={item.url}
onChange={(event) =>
handleSocialChange(item.id, "url", event.target.value)
}
placeholder={`Nhập link ${item.label}...`}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Thứ tự hiển thị</Label>
<Input
type="number"
min={1}
value={item.sortOrder}
onChange={(event) =>
handleSocialChange(
item.id,
"sortOrder",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
</Tabs> </Tabs>
<ConfigItemDialog
open={itemDialogOpen}
mode={itemDialogMode}
form={itemForm}
previewMedia={previewMedia}
saving={savingItem}
title={
editingItemId
? itemDialogMode === "logo"
? "Cập nhật logo"
: "Chỉnh sửa banner"
: itemDialogMode === "logo"
? "Thiết lập logo"
: "Thêm banner mới"
}
description={
itemDialogMode === "logo"
? "Thiết lập logo hiển thị cho website."
: "Thiết lập banner hiển thị cho trang chủ."
}
onOpenChange={setItemDialogOpen}
onChange={(key, value) =>
setItemForm((previous) => ({ ...previous, [key]: value }))
}
onPickImage={() => setImagePickerOpen(true)}
onSubmit={handleSubmitItem}
/>
<AdminImagePicker
open={imagePickerOpen}
selectedId={itemForm.imageId}
onOpenChange={setImagePickerOpen}
onSelect={(item) => {
setMediaItems((previous) => {
const nextMap = new Map(previous.map((entry) => [entry.id, entry]));
nextMap.set(item.id, item);
return Array.from(nextMap.values());
});
setItemForm((previous) => ({ ...previous, imageId: item.id }));
}}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa cấu hình"
description={
<>
Bạn có chắc muốn xóa{" "}
<span className="font-semibold">{deleteTarget?.name}</span>? Hành
động này không thể hoàn tác.
</>
}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={handleDeleteItem}
/>
</div> </div>
); );
} }
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
import * as React from "react"; import * as React from "react";
import { Image as ImageIcon, Pencil, Plus, Type, Upload, X } from "lucide-react"; import { Image as ImageIcon, Pencil, Plus, Type, Upload, X } from "lucide-react";
import Image from "next/image";
import { AdminImagePicker } from "@/components/admin/image-picker"; import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminRichTextEditor } from "@/components/shared/rich-text-editor"; import { AdminRichTextEditor } from "@/components/shared/rich-text-editor";
import { SafeImage } from "@/components/shared/safe-image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
...@@ -409,7 +409,7 @@ export function AdminPostContentEditor({ ...@@ -409,7 +409,7 @@ export function AdminPostContentEditor({
}} }}
className="relative h-64 w-full cursor-pointer" className="relative h-64 w-full cursor-pointer"
> >
<SafeImage <Image
src={currentImage.image.url} src={currentImage.image.url}
alt={currentImage.image.alt || currentImage.image.name} alt={currentImage.image.alt || currentImage.image.name}
fill fill
......
...@@ -200,7 +200,7 @@ function Footer() { ...@@ -200,7 +200,7 @@ function Footer() {
return ( return (
<footer className="w-full bg-[#202f67] text-white"> <footer className="w-full bg-[#202f67] text-white">
<div className="container mx-auto px-5 py-10 sm:px-6 lg:px-10 lg:py-12"> <div className="container mx-auto px-5 py-10 sm:px-6 lg:px-10 lg:pt-12 lg:pb-6">
<div className="grid gap-10 lg:grid-cols-[1.15fr_1.05fr_0.9fr]"> <div className="grid gap-10 lg:grid-cols-[1.15fr_1.05fr_0.9fr]">
<div> <div>
<h2 className="client-footer-title uppercase"> <h2 className="client-footer-title uppercase">
......
import type { DynamicPostContentSection } from "@/api/vcci-news/types/post"; import type { DynamicPostContentSection } from "@/api/vcci-news/types/post";
import Image from "next/image";
import { ImageLightbox } from "../../image-lightbox"; import { ImageLightbox } from "../../image-lightbox";
import { SafeImage } from "../../safe-image";
import { useState } from "react"; import { useState } from "react";
function getGridClassName(columns: number) { function getGridClassName(columns: number) {
...@@ -39,7 +39,7 @@ export function ImageSection({ section }: { section: DynamicPostContentSection } ...@@ -39,7 +39,7 @@ export function ImageSection({ section }: { section: DynamicPostContentSection }
}) })
} }
> >
<SafeImage <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}
......
"use client";
import Image, { type ImageProps } from "next/image";
import { useEffect, useState } from "react";
interface SafeImageProps extends Omit<ImageProps, "src"> {
src?: string | null;
fallbackSrc?: string;
}
export function SafeImage({
src,
alt,
fallbackSrc = "/img-error.png",
...props
}: SafeImageProps) {
const [currentSrc, setCurrentSrc] = useState(src || fallbackSrc);
const [hasFailed, setHasFailed] = useState(false);
useEffect(() => {
setCurrentSrc(src || fallbackSrc);
setHasFailed(false);
}, [fallbackSrc, src]);
return (
<Image
{...props}
src={currentSrc}
alt={alt}
onError={() => {
if (hasFailed || currentSrc === fallbackSrc) return;
setHasFailed(true);
setCurrentSrc(fallbackSrc);
}}
unoptimized
/>
);
}
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